diff --git a/README.md b/README.md index 90ac0e13..c9c47883 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ GROQ_API_KEY= PINECONE_API_KEY = PORT = 8000 SEARCH_KEY = +HF_TOKEN = ``` *Run backend:* diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..8bda6017 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +GROQ_API_KEY= +GROQ_MODEL=llama-3.3-70b-versatile +PINECONE_API_KEY = +PORT = 5555 +HF_TOKEN= +GEMINI_MODEL= +GEMINI_API_KEY= \ No newline at end of file diff --git a/backend/app/llm_config.py b/backend/app/llm_config.py new file mode 100644 index 00000000..34a1161b --- /dev/null +++ b/backend/app/llm_config.py @@ -0,0 +1,43 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +LLM_MODEL = os.getenv("GROQ_MODEL_NAME", "llama-3.3-70b-versatile") + + +def get_llm(provider: str = "groq", temperature: float = 0.7): + """Return a LangChain chat model for the requested provider. + + Supported providers: ``"groq"`` (default), ``"gemini"``. + API keys and model names are read from environment variables so that + users can bring their own keys (BYOK). + """ + + if provider == "gemini": + from langchain_google_genai import ChatGoogleGenerativeAI + + api_key = os.getenv("GEMINI_API_KEY") + model_name = os.getenv("GEMINI_MODEL_NAME", "gemini-2.5-flash") + if not api_key: + raise ValueError( + "GEMINI_API_KEY environment variable is required for Gemini" + ) + return ChatGoogleGenerativeAI( + model=model_name, + google_api_key=api_key, + temperature=temperature, + ) + + # Default → Groq + from langchain_groq import ChatGroq + + api_key = os.getenv("GROQ_API_KEY") + model_name = os.getenv("GROQ_MODEL_NAME", "llama-3.3-70b-versatile") + if not api_key: + raise ValueError("GROQ_API_KEY environment variable is required for Groq") + return ChatGroq( + model=model_name, + api_key=api_key, + temperature=temperature, + ) \ No newline at end of file diff --git a/backend/app/modules/bias_detection/check_bias.py b/backend/app/modules/bias_detection/check_bias.py index a0644529..d814fdaa 100644 --- a/backend/app/modules/bias_detection/check_bias.py +++ b/backend/app/modules/bias_detection/check_bias.py @@ -27,6 +27,7 @@ from dotenv import load_dotenv import json from app.logging.logging_config import setup_logger +from app.llm_config import LLM_MODEL logger = setup_logger(__name__) @@ -61,7 +62,7 @@ def check_bias(text): "content": (f"Give bias score to the following article \n\n{text}"), }, ], - model="gemma2-9b-it", + model=LLM_MODEL, temperature=0.3, max_tokens=512, ) diff --git a/backend/app/modules/chat/chat_graph.py b/backend/app/modules/chat/chat_graph.py new file mode 100644 index 00000000..58e0634d --- /dev/null +++ b/backend/app/modules/chat/chat_graph.py @@ -0,0 +1,97 @@ +""" +chat_graph.py +------------- +LangGraph-based conversational agent with persistent memory. +""" + +from typing import Annotated +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.checkpoint.memory import MemorySaver +from langchain_core.messages import SystemMessage, HumanMessage +from typing_extensions import TypedDict +from app.llm_config import get_llm +from app.logging.logging_config import setup_logger + +logger = setup_logger(__name__) + + +class ChatState(TypedDict): + messages: Annotated[list, add_messages] + + +_memory = MemorySaver() + + +def _chatbot_node(state: ChatState, config: dict): + provider = config.get("configurable", {}).get("provider", "groq") + llm = get_llm(provider=provider, temperature=0.7) + response = llm.invoke(state["messages"]) + return {"messages": [response]} + + +_graph = StateGraph(ChatState) +_graph.add_node("chatbot", _chatbot_node) +_graph.add_edge(START, "chatbot") +_graph.add_edge("chatbot", END) +chat_app = _graph.compile(checkpointer=_memory) + + +async def initialize_chat_thread(thread_id: str, analysis_result: dict) -> None: + perspective_obj = analysis_result.get("perspective", {}) + if hasattr(perspective_obj, "model_dump"): + p = perspective_obj.model_dump() + elif hasattr(perspective_obj, "dict"): + p = perspective_obj.dict() + elif isinstance(perspective_obj, dict): + p = perspective_obj + else: + p = {"perspective": str(perspective_obj)} + + facts = analysis_result.get("facts", []) + facts_text = "\n".join( + f"- {f.get('claim', 'N/A')}: {f.get('status', '?')} -- {f.get('reason', '')}" + for f in facts + ) or "No facts were verified." + + citations = analysis_result.get("web_search_citations", []) + citations_text = "\n".join( + f"- {c.get('title', 'Untitled')} ({c.get('url', '')})" + for c in citations + ) or "No citations available." + + summary = analysis_result.get("article_summary", "No summary available.") + sentiment = analysis_result.get("sentiment", "unknown") + perspective_text = p.get("perspective", "") + + system_content = ( + "You are an AI assistant helping the user understand and discuss a " + "news article that has been analyzed. Here is the full analysis:\n\n" + f"**Article Summary:**\n{summary}\n\n" + f"**Detected Sentiment:** {sentiment}\n\n" + f"**Counter-Perspective:**\n{perspective_text}\n\n" + f"**Fact-Check Results:**\n{facts_text}\n\n" + f"**Web Search Citations:**\n{citations_text}\n\n" + "Guidelines:\n" + "- Answer questions using this context.\n" + "- Be balanced and cite facts when relevant.\n" + "- Be transparent if something is not covered.\n" + "- Keep responses concise but thorough." + ) + + config = {"configurable": {"thread_id": thread_id, "provider": "groq"}} + await chat_app.ainvoke( + {"messages": [SystemMessage(content=system_content)]}, config=config + ) + logger.info(f"Chat thread {thread_id} initialised with article context.") + + +async def send_chat_message( + thread_id: str, message: str, provider: str = "groq" +) -> str: + config = {"configurable": {"thread_id": thread_id, "provider": provider}} + result = await chat_app.ainvoke( + {"messages": [HumanMessage(content=message)]}, config=config + ) + ai_msg = result["messages"][-1] + return ai_msg.content if hasattr(ai_msg, "content") else str(ai_msg) \ No newline at end of file diff --git a/backend/app/modules/chat/get_rag_data.py b/backend/app/modules/chat/get_rag_data.py index 092e4587..23f9a37c 100644 --- a/backend/app/modules/chat/get_rag_data.py +++ b/backend/app/modules/chat/get_rag_data.py @@ -31,7 +31,7 @@ load_dotenv() -pc = Pinecone(os.getenv("PINECONE_API_KEY")) +pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY")) index = pc.Index("perspective") diff --git a/backend/app/modules/chat/llm_processing.py b/backend/app/modules/chat/llm_processing.py index 2d5134fa..9fe0ace3 100644 --- a/backend/app/modules/chat/llm_processing.py +++ b/backend/app/modules/chat/llm_processing.py @@ -27,6 +27,7 @@ from groq import Groq from dotenv import load_dotenv from app.logging.logging_config import setup_logger +from app.llm_config import LLM_MODEL logger = setup_logger(__name__) @@ -55,7 +56,7 @@ def ask_llm(question, docs): """ response = client.chat.completions.create( - model="gemma2-9b-it", + model=LLM_MODEL, messages=[ {"role": "system", "content": "Use only the context to answer."}, {"role": "user", "content": prompt}, diff --git a/backend/app/modules/fact_check_tool.py b/backend/app/modules/fact_check_tool.py new file mode 100644 index 00000000..d9f1fbdf --- /dev/null +++ b/backend/app/modules/fact_check_tool.py @@ -0,0 +1,216 @@ +import os +import json +import asyncio +from groq import Groq +from duckduckgo_search import DDGS +from app.logging.logging_config import setup_logger +from dotenv import load_dotenv +from app.llm_config import LLM_MODEL + +load_dotenv() + +client = Groq(api_key=os.getenv("GROQ_API_KEY")) +logger = setup_logger(__name__) + + +async def extract_claims_node(state): + logger.info("--- Fact Check Step 1: Extracting Claims ---") + try: + text = state.get("cleaned_text", "") + response = await asyncio.to_thread( + client.chat.completions.create, + messages=[ + { + "role": "system", + "content": ( + "Extract specific, verifiable factual claims from the text. " + "Ignore opinions. Return a simple list of strings, one per line." + ), + }, + {"role": "user", "content": text[:4000]}, + ], + model=LLM_MODEL, + temperature=0.0, + ) + raw_content = response.choices[0].message.content + claims = [ + line.strip("- *") + for line in raw_content.split("\n") + if len(line.strip()) > 10 + ] + logger.info(f"Extracted {len(claims)} claims.") + return {"claims": claims} + except Exception as e: + logger.error(f"Error extracting claims: {e}") + return {"claims": []} + + +async def plan_searches_node(state): + logger.info("--- Fact Check Step 2: Planning Searches ---") + claims = state.get("claims", []) + if not claims: + return {"search_queries": []} + + claims_text = "\n".join([f"{i}. {c}" for i, c in enumerate(claims)]) + prompt = ( + "You are a search query generator.\n" + "For each claim, generate a search query to verify it.\n" + "Output MUST be valid JSON in this format:\n" + '{"searches": [{"query": "search query 1", "claim_id": 0}, ' + '{"query": "search query 2", "claim_id": 1}]}\n\n' + f"Claims:\n{claims_text}" + ) + try: + response = await asyncio.to_thread( + client.chat.completions.create, + messages=[{"role": "user", "content": prompt}], + model=LLM_MODEL, + temperature=0.0, + response_format={"type": "json_object"}, + ) + plan_json = json.loads(response.choices[0].message.content) + queries = plan_json.get("searches", []) + return {"search_queries": queries} + except Exception as e: + logger.error(f"Failed to plan searches: {e}") + return {"search_queries": []} + + +async def execute_searches_node(state): + """Execute parallel web searches and collect structured citations.""" + logger.info("--- Fact Check Step 3: Executing Parallel Searches ---") + queries = state.get("search_queries", []) + if not queries: + return {"search_results": [], "web_search_citations": []} + + all_citations: list[dict] = [] + + async def run_one_search(q): + nonlocal all_citations + try: + query_str = q.get("query", "") + c_id = q.get("claim_id") + + def do_search(): + with DDGS() as ddgs: + return list(ddgs.text(query_str, max_results=3)) + + raw_results = await asyncio.to_thread(do_search) + + citations = [] + snippets = [] + for r in raw_results: + citations.append( + { + "title": r.get("title", ""), + "url": r.get("href", ""), + "snippet": r.get("body", ""), + } + ) + snippets.append(r.get("body", "")) + + all_citations.extend(citations) + result_text = " ".join(snippets) + logger.info(f"Search for Claim {c_id}: {result_text[:200]}...") + return {"claim_id": c_id, "result": result_text, "citations": citations} + except Exception as e: + logger.error(f"Search failed for query: {e}") + return { + "claim_id": q.get("claim_id"), + "result": "Search failed", + "citations": [], + } + + results = await asyncio.gather(*[run_one_search(q) for q in queries]) + logger.info(f"Completed {len(results)} searches.") + + # Deduplicate citations by URL + seen_urls: set[str] = set() + unique_citations = [] + for c in all_citations: + url = c.get("url", "") + if url and url not in seen_urls: + seen_urls.add(url) + unique_citations.append(c) + + return { + "search_results": list(results), + "web_search_citations": unique_citations, + } + + +async def verify_facts_node(state): + """Verify extracted claims against search evidence.""" + logger.info("--- Fact Check Step 4: Verifying Facts ---") + claims = state.get("claims", []) + results = state.get("search_results", []) + + if not claims: + return {"facts": [], "fact_check_done": True} + + context = "Verify these claims based on the search results:\n" + for item in results: + # --- FIX: Defensive claim_id checks --- + if "claim_id" not in item: + logger.warning(f"Skipping result with missing claim_id: {item}") + continue + + try: + c_id = int(item["claim_id"]) + except (ValueError, TypeError): + logger.warning(f"Invalid claim_id value: {item.get('claim_id')!r}") + continue + + if not (0 <= c_id < len(claims)): + logger.warning( + f"claim_id {c_id} out of bounds (total claims: {len(claims)})" + ) + continue + + context += f"\nClaim: {claims[c_id]}\nEvidence: {item['result']}\n" + + try: + # --- FIX: Prompt requests a JSON object, not a bare array --- + response = await asyncio.to_thread( + client.chat.completions.create, + messages=[ + { + "role": "system", + "content": ( + "You are a strict fact checker. Return a JSON **object** with " + 'a "facts" key containing a list of objects. Each object must ' + 'have keys: "claim" (string), "status" (one of "True", "False", ' + 'or "Unverified"), and "reason" (string). ' + 'Example: {"facts": [{"claim": "...", "status": "True", ' + '"reason": "..."}]}' + ), + }, + {"role": "user", "content": context}, + ], + model=LLM_MODEL, + temperature=0.0, + response_format={"type": "json_object"}, + ) + final_verdict_str = response.choices[0].message.content + data = json.loads(final_verdict_str) + + facts_list = [] + if isinstance(data, dict): + if "facts" in data: + facts_list = data["facts"] + elif "verified_claims" in data: + facts_list = data["verified_claims"] + else: + for v in data.values(): + if isinstance(v, list): + facts_list = v + break + else: + facts_list = [data] + elif isinstance(data, list): + facts_list = data + + return {"facts": facts_list, "fact_check_done": True} + except Exception as e: + logger.error(f"Verification failed: {e}") + return {"facts": [], "fact_check_done": True} \ No newline at end of file diff --git a/backend/app/modules/facts_check/__init__.py b/backend/app/modules/facts_check/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/app/modules/facts_check/llm_processing.py b/backend/app/modules/facts_check/llm_processing.py deleted file mode 100644 index dc223a85..00000000 --- a/backend/app/modules/facts_check/llm_processing.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -llm_processing.py ------------------ -Handles claim extraction and fact verification tasks using the Groq LLM API. - -This module: - - Connects to the Groq API with credentials from environment variables. - - Extracts verifiable factual claims from text. - - Verifies claims using provided search results and evidence. - - Returns structured responses with verdicts and explanations. - -Functions: - run_claim_extractor_sdk(state: dict) -> dict: - Extracts up to three concise, verifiable claims from the input text - stored in the `state` dictionary. - - run_fact_verifier_sdk(search_results: list[dict]) -> dict: - Evaluates provided claims against web search evidence and returns - structured JSON verdicts for each claim. - -Environment Variables: - GROQ_API_KEY (str): API key for authenticating with Groq. -""" - - -import os -from groq import Groq -from dotenv import load_dotenv -import json -import re -from app.logging.logging_config import setup_logger - -logger = setup_logger(__name__) - -load_dotenv() - -client = Groq(api_key=os.getenv("GROQ_API_KEY")) - - -def run_claim_extractor_sdk(state): - try: - text = state.get("cleaned_text") - if not text: - raise ValueError("Missing or empty 'cleaned_text' in state") - - chat_completion = client.chat.completions.create( - messages=[ - { - "role": "system", - "content": ( - "You are an assistant that extracts " - "verifiable factual claims from articles. " - "Each claim must be short, fact-based, and" - " independently verifiable through internet search. " - "Only return a list of 3 clear bullet-point claims." - ), - }, - { - "role": "user", - "content": ( - f"Extract verifiable claims " - f"from the following article:\n\n{text}" - ), - }, - ], - model="gemma2-9b-it", - temperature=0.3, - max_tokens=512, - ) - - extracted_claims = chat_completion.choices[0].message.content.strip() - logger.debug(f"Extracted claims:\n{extracted_claims}") - - - return { - **state, - "verifiable_claims": extracted_claims, - "status": "success", - } - - except Exception as e: - logger.exception("Error in claim_extraction") - return { - "status": "error", - "error_from": "claim_extraction", - "message": str(e), - } - - -def run_fact_verifier_sdk(search_results): - try: - results_list = [] - - for result in search_results: - source = result.get("link", "N/A") - claim = result.get("claim", "N/A") - evidence = ( - f"{result.get('title', '')}" - f"\n{result.get('snippet', '')}" - f"\nLink: {source}" - ) - - chat_completion = client.chat.completions.create( - messages=[ - { - "role": "system", - "content": ( - "You are a fact-checking assistant. " - "Your job is to determine whether the given" - " claim is True, False" - "based on the provided web search evidence." - " Keep it concise and structured." - ), - }, - { - "role": "user", - "content": ( - f"Claim: {claim}\n\n" - f"Web Evidence:\n{evidence}\n\n" - "Based on this evidence, is the claim true?\n" - "Respond only in this JSON format:\n\n" - "{\n" - ' "verdict": "True" | "False",\n' - ' "explanation": "...",\n' - f' "original_claim": "{claim}",\n' - f' "source_link": "{source}"\n' - "}" - ), - }, - ], - model="gemma2-9b-it", - temperature=0.3, - max_tokens=256, - ) - - content = chat_completion.choices[0].message.content.strip() - - # Strip markdown code blocks if present - content = re.sub(r"^```json|```$", "", content).strip() - logger.debug(f"Raw LLM fact verification output:\n{content}") - - # Try parsing the JSON response - try: - parsed = json.loads(content) - except Exception as parse_err: - logger.error(f"LLM JSON parse error: {parse_err}") - - results_list.append(parsed) - - return { - "claim": claim, - "verifications": results_list, - "status": "success", - } - - except Exception as e: - logger.exception("Error in fact_verification") - return { - "status": "error", - "error_from": "fact_verification", - "message": str(e), - } diff --git a/backend/app/modules/facts_check/web_search.py b/backend/app/modules/facts_check/web_search.py deleted file mode 100644 index 527b3cc9..00000000 --- a/backend/app/modules/facts_check/web_search.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -web-search.py -------------- -Provides a simple wrapper for performing Google Custom Search queries. - -This module: - - Loads the Google Search API key from environment variables. - - Sends search requests to the Google Custom Search API. - - Returns the first search result with title, link, and snippet. - -Functions: - search_google(query: str) -> list[dict]: - Executes a Google search for the given query and returns the top result - in a list containing its title, link, and snippet. - -Environment Variables: - SEARCH_KEY (str): API key for Google Custom Search API. -""" - - -import requests -from dotenv import load_dotenv -import os - -load_dotenv() - -GOOGLE_SEARCH = os.getenv("SEARCH_KEY") - - -def search_google(query): - results = requests.get( - f"https://www.googleapis.com/customsearch/v1?key={GOOGLE_SEARCH}&cx=f637ab77b5d8b4a3c&q={query}" - ) - res = results.json() - first = {} - first["title"] = res["items"][0]["title"] - first["link"] = res["items"][0]["link"] - first["snippet"] = res["items"][0]["snippet"] - - return [ - first, - ] diff --git a/backend/app/modules/langgraph_builder.py b/backend/app/modules/langgraph_builder.py index 7729f945..59b00905 100644 --- a/backend/app/modules/langgraph_builder.py +++ b/backend/app/modules/langgraph_builder.py @@ -6,12 +6,12 @@ and retry logic. Workflow: - 1. Sentiment analysis on the cleaned text. - 2. Fact-checking detected claims. - 3. Generating a counter-perspective. - 4. Judging the quality of the generated perspective. - 5. Storing results and sending them downstream. - 6. Error handling at any step if failures occur. + 1. Parallel analysis: sentiment analysis and fact checking tool pipeline + (extract_claims -> plan_searches -> execute_searches -> verify_facts) + 2. Generating a counter-perspective. + 3. Judging the quality of the generated perspective. + 4. Storing results and sending. + 5. Error handling at any step if failures occur. Core Features: - Uses a TypedDict (`MyState`) to define the shape of the pipeline's @@ -29,52 +29,53 @@ Creates the StateGraph, adds processing nodes, defines transitions, and compiles the graph for execution. """ - - +from typing import List, Any, Dict from langgraph.graph import StateGraph +from langgraph.checkpoint.memory import MemorySaver +from typing_extensions import TypedDict from app.modules.langgraph_nodes import ( sentiment, - fact_check, generate_perspective, judge, store_and_send, error_handler, ) -from typing_extensions import TypedDict - class MyState(TypedDict): cleaned_text: str facts: list[dict] sentiment: str perspective: str + short_title: str score: int retries: int status: str + claims: List[str] + search_queries: List[Any] + search_results: List[Any] + article_summary: str + web_search_citations: List[Dict[str, str]] + thread_id: str + provider: str # ← NEW: routed from the frontend request + + +memory = MemorySaver() def build_langgraph(): graph = StateGraph(MyState) - graph.add_node("sentiment_analysis", sentiment.run_sentiment_sdk) - graph.add_node("fact_checking", fact_check.run_fact_check) + graph.add_node("parallel_analysis", sentiment.run_parallel_analysis) graph.add_node("generate_perspective", generate_perspective.generate_perspective) graph.add_node("judge_perspective", judge.judge_perspective) graph.add_node("store_and_send", store_and_send.store_and_send) graph.add_node("error_handler", error_handler.error_handler) - graph.set_entry_point( - "sentiment_analysis", - ) - - graph.add_conditional_edges( - "sentiment_analysis", - lambda x: ("error_handler" if x.get("status") == "error" else "fact_checking"), - ) + graph.set_entry_point("parallel_analysis") graph.add_conditional_edges( - "fact_checking", + "parallel_analysis", lambda x: ( "error_handler" if x.get("status") == "error" else "generate_perspective" ), @@ -94,18 +95,16 @@ def build_langgraph(): if state.get("status") == "error" else ( "store_and_send" - if state.get("retries", 0) >= 3 + if state.get("score", 0) >= 70 or state.get("retries", 0) >= 3 else "generate_perspective" ) - if state.get("score", 0) < 70 - else "store_and_send" ), ) + graph.add_conditional_edges( "store_and_send", - lambda x: ("error_handler" if x.get("status") == "error" else "__end__"), + lambda x: "error_handler" if x.get("status") == "error" else "end", ) graph.set_finish_point("store_and_send") - - return graph.compile() + return graph.compile(checkpointer=memory) \ No newline at end of file diff --git a/backend/app/modules/langgraph_nodes/generate_perspective.py b/backend/app/modules/langgraph_nodes/generate_perspective.py index be0c81f3..98701b52 100644 --- a/backend/app/modules/langgraph_nodes/generate_perspective.py +++ b/backend/app/modules/langgraph_nodes/generate_perspective.py @@ -20,66 +20,64 @@ """ -from app.utils.prompt_templates import generation_prompt -from langchain_groq import ChatGroq +import asyncio +from typing import List + from pydantic import BaseModel, Field +from langchain.schema.runnable import RunnableSequence + +from app.utils.prompt_templates import generation_prompt +from app.llm_config import get_llm from app.logging.logging_config import setup_logger logger = setup_logger(__name__) -prompt = generation_prompt - - class PerspectiveOutput(BaseModel): - reasoning: str = Field(..., description="Chain-of-thought reasoning steps") - perspective: str = Field(..., description="Generated opposite perspective") - - -my_llm = "llama-3.3-70b-versatile" + short_title: str = Field(description="A catchy, concise title for this analysis (max 10 words)") + perspective: str = Field(description="Generated opposite perspective") + reasoning_steps: List[str] = Field(description="Chain-of-thought reasoning steps") -llm = ChatGroq(model=my_llm, temperature=0.7) -structured_llm = llm.with_structured_output(PerspectiveOutput) - - -chain = prompt | structured_llm - - -def generate_perspective(state): +async def generate_perspective(state: dict) -> dict: try: - retries = state.get("retries", 0) - state["retries"] = retries + 1 - - text = state["cleaned_text"] + retries = state.get("retries", 0) + 1 + text = state.get("cleaned_text", "") facts = state.get("facts") + provider = state.get("provider", "groq") if not text: raise ValueError("Missing or empty 'cleaned_text' in state") - elif not facts: - raise ValueError("Missing or empty 'facts' in state") - - facts_str = "\n".join( - [ - f"Claim: {f['original_claim']}\n" - "Verdict: {f['verdict']}\nExplanation: " - "{f['explanation']}" - for f in state["facts"] - ] - ) - result = chain.invoke( + # Build the chain dynamically based on provider + llm = get_llm(provider, temperature=0.7) + structured_llm = llm.with_structured_output(PerspectiveOutput) + chain: RunnableSequence = generation_prompt | structured_llm + + if not facts: + logger.warning("No facts found. Generating perspective based on text only.") + facts_str = "No specific claims verified." + else: + facts_str = "\n".join( + [ + f"Claim: {f.get('claim', f.get('original_claim', 'Unknown'))}\n" + f"Verdict: {f.get('status', f.get('verdict', 'Unknown'))}\n" + f"Explanation: {f.get('reason', f.get('explanation', 'No explanation'))}" + for f in facts + ] + ) + + result = await asyncio.to_thread( + chain.invoke, { "cleaned_article": text, "facts": facts_str, "sentiment": state.get("sentiment", "neutral"), - } + }, ) + + return {**state, "perspective": result, "retries": retries, "status": "success"} + except Exception as e: logger.exception(f"Error in generate_perspective: {e}") - return { - "status": "error", - "error_from": "generate_perspective", - "message": f"{e}", - } - return {**state, "perspective": result, "status": "success"} + return {"status": "error", "error_from": "generate_perspective", "message": str(e)} \ No newline at end of file diff --git a/backend/app/modules/langgraph_nodes/judge.py b/backend/app/modules/langgraph_nodes/judge.py index 57100301..ce0a11e7 100644 --- a/backend/app/modules/langgraph_nodes/judge.py +++ b/backend/app/modules/langgraph_nodes/judge.py @@ -1,73 +1,58 @@ -""" -judge.py --------- -Evaluates a generated counter-perspective using an LLM-based scoring system. - -This module: - - Uses Groq's LLM to rate the originality, reasoning quality, - and factual grounding of a generated perspective. - - Returns a score from 0 (very poor) to 100 (excellent). - - Handles parsing errors and unexpected responses gracefully. - -Functions: - judge_perspective(state: dict) -> dict: - Evaluates the given perspective and returns an integer score with status metadata. -""" - - import re -from langchain_groq import ChatGroq +import asyncio from langchain.schema import HumanMessage from app.logging.logging_config import setup_logger +from app.llm_config import get_llm logger = setup_logger(__name__) -# Init once -groq_llm = ChatGroq( - model="gemma2-9b-it", - temperature=0.0, - max_tokens=10, -) - -def judge_perspective(state): +async def judge_perspective(state: dict) -> dict: try: perspective_obj = state.get("perspective") - text = getattr(perspective_obj, "perspective", "").strip() - if not text: - raise ValueError("Empty 'perspective' for scoring") - prompt = f""" -You are an expert evaluator. Please rate the following counter-perspective -on originality, reasoning quality, and factual grounding. Provide ONLY -a single integer score from 0 (very poor) to 100 (excellent). - -=== Perspective to score === -{text} -""" - - response = groq_llm.invoke([HumanMessage(content=prompt)]) - - if isinstance(response, list) and response: - raw = response[0].content.strip() - elif hasattr(response, "content"): - raw = response.content.strip() + # Extract the actual text from whichever shape perspective_obj has + if hasattr(perspective_obj, "perspective"): + text = perspective_obj.perspective + elif isinstance(perspective_obj, dict): + text = perspective_obj.get("perspective", "") else: - raw = str(response).strip() - - # 5) Pull the first integer 0–100 - m = re.search(r"\b(\d{1,3})\b", raw) - if not m: - raise ValueError(f"Couldn’t parse a score from: '{raw}'") - - score = max(0, min(100, int(m.group(1)))) + text = str(perspective_obj) if perspective_obj else "" + if not text: + logger.warning("No perspective text found to judge.") + return {**state, "score": 0, "status": "success"} + + provider = state.get("provider", "groq") + llm = get_llm(provider, temperature=0.3) + + prompt = ( + "Rate the following counter-perspective on a scale of 0-100 based on:\n" + "1. Originality and insight\n" + "2. Quality of reasoning\n" + "3. Factual grounding\n\n" + f"Perspective:\n{text}\n\n" + "Return ONLY a number between 0 and 100. No text, no explanation." + ) + + response = await asyncio.to_thread( + llm.invoke, [HumanMessage(content=prompt)] + ) + + content = response.content.strip() + numbers = re.findall(r"\d+", content) + score = int(numbers[0]) if numbers else 50 + score = max(0, min(100, score)) + + logger.info(f"Judge score: {score}") return {**state, "score": score, "status": "success"} except Exception as e: logger.exception(f"Error in judge_perspective: {e}") return { + **state, + "score": 50, "status": "error", - "error_from": "judge_perspective", + "error_from": "judge", "message": str(e), - } + } \ No newline at end of file diff --git a/backend/app/modules/langgraph_nodes/sentiment.py b/backend/app/modules/langgraph_nodes/sentiment.py index fef1d39d..93ee28f6 100644 --- a/backend/app/modules/langgraph_nodes/sentiment.py +++ b/backend/app/modules/langgraph_nodes/sentiment.py @@ -1,68 +1,118 @@ """ sentiment.py ------------ -Performs sentiment analysis on cleaned article text using Groq's LLM. - -This module: - - Accepts pre-processed article text from the pipeline state. - - Uses an LLM to classify sentiment as Positive, Negative, or Neutral. - - Returns the sentiment label along with updated pipeline state. - -Functions: - run_sentiment_sdk(state: dict) -> dict: - Analyzes sentiment and updates the state with the result. +Parallel analysis node: sentiment + fact-check + summary. +Supports provider-based LLM routing (BYOK). """ - -import os -from groq import Groq +import asyncio +from langchain_core.messages import SystemMessage, HumanMessage from dotenv import load_dotenv + from app.logging.logging_config import setup_logger +from app.llm_config import get_llm +from app.modules import fact_check_tool logger = setup_logger(__name__) - load_dotenv() -client = Groq(api_key=os.getenv("GROQ_API_KEY")) - -def run_sentiment_sdk(state): +# --------------------------------------------------------------------------- +# Public entry-point (LangGraph node) +# --------------------------------------------------------------------------- + +async def run_parallel_analysis(state): + provider = state.get("provider", "groq") + + sentiment_task = asyncio.to_thread(run_sentiment, state, provider) + fact_check_task = _run_fact_check_pipeline(state) + summary_task = asyncio.to_thread(generate_summary, state, provider) + + sentiment_result, fact_check_result, summary_result = await asyncio.gather( + sentiment_task, fact_check_task, summary_task + ) + + for result, source in [ + (sentiment_result, "sentiment_analysis"), + (fact_check_result, "fact_checking"), + (summary_result, "summary_generation"), + ]: + if result.get("status") == "error": + return { + "status": "error", + "error_from": result.get("error_from", source), + "message": result.get("message", "Unknown error"), + } + + return { + **state, + "sentiment": sentiment_result.get("sentiment"), + "claims": fact_check_result.get("claims", []), + "search_queries": fact_check_result.get("search_queries", []), + "search_results": fact_check_result.get("search_results", []), + "facts": fact_check_result.get("facts", []), + "web_search_citations": fact_check_result.get("web_search_citations", []), + "article_summary": summary_result.get("article_summary", ""), + "status": "success", + } + + +# --------------------------------------------------------------------------- +# Fact-check sub-pipeline (always uses Groq internally for JSON-mode) +# --------------------------------------------------------------------------- + +async def _run_fact_check_pipeline(state): try: - text = state.get("cleaned_text") - if not text: - raise ValueError("Missing or empty 'cleaned_text' in state") + claims_result = await fact_check_tool.extract_claims_node(state) + current_state = {**state, **claims_result} + + searches_result = await fact_check_tool.plan_searches_node(current_state) + current_state = {**current_state, **searches_result} - chat_completion = client.chat.completions.create( - messages=[ - { - "role": "system", - "content": ( - "You are a sentiment analysis assistant. " - "Only respond with one word:" - " Positive, Negative, or Neutral." - ), - }, - { - "role": "user", - "content": ( - f"Analyze the sentiment of the following text:\n\n{text}" - ), - }, - ], - model="gemma2-9b-it", - temperature=0.2, - max_tokens=3, - ) - - sentiment = chat_completion.choices[0].message.content.strip() - sentiment = sentiment.lower() + exec_result = await fact_check_tool.execute_searches_node(current_state) + current_state = {**current_state, **exec_result} + + verify_result = await fact_check_tool.verify_facts_node(current_state) + current_state = {**current_state, **verify_result} return { - **state, - "sentiment": sentiment, + "claims": current_state.get("claims", []), + "search_queries": current_state.get("search_queries", []), + "search_results": current_state.get("search_results", []), + "facts": current_state.get("facts", []), + "web_search_citations": current_state.get("web_search_citations", []), "status": "success", } + except Exception as e: + logger.exception(f"Error in fact_check_pipeline: {e}") + return { + "status": "error", + "error_from": "fact_checking", + "message": str(e), + } + + +# --------------------------------------------------------------------------- +# Sentiment (provider-aware) +# --------------------------------------------------------------------------- + +def run_sentiment(state, provider: str = "groq"): + try: + text = state.get("cleaned_text") + if not text: + raise ValueError("Missing or empty 'cleaned_text' in state") + llm = get_llm(provider, temperature=0.2) + response = llm.invoke([ + SystemMessage(content=( + "You are a sentiment analysis assistant. " + "Only respond with one word: Positive, Negative, or Neutral." + )), + HumanMessage(content=f"Analyze the sentiment of the following text:\n\n{text[:4000]}"), + ]) + sentiment = response.content.strip().lower() + logger.info(f"Sentiment result: {sentiment}") + return {"sentiment": sentiment, "status": "success"} except Exception as e: logger.exception(f"Error in sentiment_analysis: {e}") return { @@ -72,13 +122,32 @@ def run_sentiment_sdk(state): } -# if __name__ == "__main__": -# dummy_state = { -# "cleaned_text": ( -# "The 2025 French Open men’s final at Roland Garros was more than" -# "just a sporting event." -# ) -# } +# --------------------------------------------------------------------------- +# Summary (provider-aware) +# --------------------------------------------------------------------------- -# result = run_sentiment_sdk(dummy_state) -# print("Sentiment Output:", result) +def generate_summary(state, provider: str = "groq"): + try: + text = state.get("cleaned_text", "") + if not text: + return {"article_summary": "", "status": "success"} + + llm = get_llm(provider, temperature=0.3) + response = llm.invoke([ + SystemMessage(content=( + "You are a concise summarizer. Provide a clear, neutral, " + "3-5 sentence summary of the article. Focus on key facts " + "and the main argument." + )), + HumanMessage(content=f"Summarize this article:\n\n{text[:4000]}"), + ]) + summary = response.content.strip() + logger.info("Article summary generated successfully.") + return {"article_summary": summary, "status": "success"} + except Exception as e: + logger.exception(f"Error generating summary: {e}") + return { + "status": "error", + "error_from": "summary_generation", + "message": str(e), + } \ No newline at end of file diff --git a/backend/app/modules/langgraph_nodes/store_and_send.py b/backend/app/modules/langgraph_nodes/store_and_send.py index 63b814fd..86374245 100644 --- a/backend/app/modules/langgraph_nodes/store_and_send.py +++ b/backend/app/modules/langgraph_nodes/store_and_send.py @@ -14,7 +14,6 @@ Processes the given state through chunking, embedding, and storage. """ - from app.modules.vector_store.chunk_rag_data import chunk_rag_data from app.modules.vector_store.embed import embed_chunks from app.utils.store_vectors import store @@ -24,31 +23,31 @@ def store_and_send(state): - # to store data in vector db try: - logger.debug(f"Received state for vector storage: {state}") - try: - chunks = chunk_rag_data(state) - except KeyError as e: - raise Exception(f"Missing required data field for chunking: {e}") - except Exception as e: - raise Exception(f"Failed to chunk data: {e}") - try: - vectors = embed_chunks(chunks) - if vectors: - logger.info(f"Embedding complete — {len(vectors)} vectors generated.") - except Exception as e: - raise Exception(f"failed to embed chunks: {e}") - - store(vectors) - logger.info("Vectors successfully stored in Pinecone.") + logger.debug("Received state for vector storage.") + + chunks, chunk_error = chunk_rag_data(state) + if chunk_error: + logger.error(f"Chunking returned error: {chunk_error}") + + if not chunks: + logger.warning("No chunks generated. Skipping vector storage.") + return {**state, "status": "success"} + + vectors = embed_chunks(chunks) + if vectors: + logger.info(f"Embedding complete — {len(vectors)} vectors generated.") + store(vectors) + logger.info("Vectors successfully stored in Pinecone.") + else: + logger.warning("No vectors generated from embedding.") except Exception as e: logger.exception(f"Error in store_and_send: {e}") return { "status": "error", "error_from": "store_and_send", - "message": f"{e}", + "message": str(e), } - # sending to frontend - return {**state, "status": "success"} + + return {**state, "status": "success"} \ No newline at end of file diff --git a/backend/app/modules/pipeline.py b/backend/app/modules/pipeline.py index 3e4a844e..aea2a9b5 100644 --- a/backend/app/modules/pipeline.py +++ b/backend/app/modules/pipeline.py @@ -31,41 +31,59 @@ state dictionary and returns the result. """ +""" +pipeline.py +----------- +Scraper → LangGraph orchestration. +""" +import json +import uuid +import asyncio from app.modules.scraper.extractor import Article_extractor from app.modules.scraper.cleaner import clean_extracted_text from app.modules.scraper.keywords import extract_keywords from app.modules.langgraph_builder import build_langgraph +from app.modules.chat.chat_graph import initialize_chat_thread from app.logging.logging_config import setup_logger -import json logger = setup_logger(__name__) - -# Compile once when module loads _LANGGRAPH_WORKFLOW = build_langgraph() def run_scraper_pipeline(url: str) -> dict: extractor = Article_extractor(url) raw_text = extractor.extract() - - # Clean the text - result = {} cleaned_text = clean_extracted_text(raw_text["text"]) - result["cleaned_text"] = cleaned_text - - # Extract keywords - keywords = extract_keywords(cleaned_text) - result["keywords"] = keywords - + result = { + "cleaned_text": cleaned_text, + "keywords": extract_keywords(cleaned_text), + } logger.info(f"Scraper pipeline completed for URL: {url}") logger.debug(f"Scraper output: {json.dumps(result, ensure_ascii=False, indent=2)}") - return result -def run_langgraph_workflow(state: dict): - """Execute the pre-compiled LangGraph workflow.""" - result = _LANGGRAPH_WORKFLOW.invoke(state) - logger.info("LangGraph workflow executed successfully.") - return result +async def run_langgraph_workflow(state: dict, provider: str = "groq") -> dict: + """Execute the full analysis workflow. + + *provider* is injected into the state so every downstream node can + instantiate the correct LLM. + """ + state["provider"] = provider + + thread_id = str(uuid.uuid4()) + config = {"configurable": {"thread_id": thread_id}} + + result = await _LANGGRAPH_WORKFLOW.ainvoke(state, config=config) + result["thread_id"] = thread_id + logger.info(f"LangGraph workflow executed. thread_id={thread_id}") + + # Bootstrap a chat thread with the article context so the user can ask + # follow-up questions immediately. + try: + await initialize_chat_thread(thread_id, result) + except Exception: + logger.exception("Failed to initialise chat thread (non-fatal)") + + return result \ No newline at end of file diff --git a/backend/app/modules/scraper/cleaner.py b/backend/app/modules/scraper/cleaner.py index e9772bc2..ec712ddb 100644 --- a/backend/app/modules/scraper/cleaner.py +++ b/backend/app/modules/scraper/cleaner.py @@ -1,105 +1,37 @@ -""" -cleaner.py ----------- -Utility for cleaning raw extracted article text by removing boilerplate, -junk lines, and excessive whitespace. Helps prepare text for downstream -processing like NLP, fact-checking, and embedding. - -Main Features: - - Normalizes line breaks. - - Removes common boilerplate phrases and copyright notices. - - Filters out lines that are too short to be meaningful. - - Tidies spacing and formatting for readability. - -Functions: - clean_extracted_text(text: str) -> str - Cleans up extracted text by removing repetitive, promotional, or - irrelevant content while preserving main article body. -""" - import re -import nltk - -try: - nltk.data.find("corpora/stopwords") - nltk.data.find("corpora/punkt_tab") - -except LookupError: - nltk.download("stopwords") - nltk.download("punkt_tab") -def clean_extracted_text(text: str): - """ - Clean up the extracted article text to remove boilerplate, - repetitive lines, excessive whitespace, and unwanted junk. - """ +def clean_extracted_text(text: str) -> str: if not text: return "" - # 1. Removing multiple line breaks to single line break text = re.sub(r"\n{2,}", "\n\n", text) - # 2. Removing common boilerplate patterns - # (example: "Read more at...", "Subscribe", etc.) boilerplate_phrases = [ - r"read more at.*", - r"subscribe to.*", - r"click here to.*", - r"follow us on.*", - r"advertisement", - r"sponsored content", - r"promoted by.*", - r"recommended for you", - r"© \d{4}.*", # copyright lines - r"all rights reserved", - r"terms of service", - r"privacy policy", - r"cookie policy", - r"about us", - r"contact us", - r"share this article", - r"sign up for our newsletter", - r"report this ad", - r"this story was originally published.*", - r"originally appeared on.*", - r"download our app.*", - r"view comments", - r"comment below", - r"leave a comment", - r"next article", - r"previous article", - r"related articles", - r"top stories", - r"breaking news", - r"editor's picks", - r"latest news", - r"trending now", - r"this content is provided by.*", - r"image source:.*", - r"photo by.*", - r"disclaimer:.*", - r"support independent journalism.*", - r"if you enjoyed this article.*", - r"don’t miss out on.*", - r"watch the video", - r"listen to the podcast", - r"stay connected with.*", - r"visit our homepage.*", - r"post a job on.*", - r"powered by .*", + r"read more at.*", r"subscribe to.*", r"click here to.*", + r"follow us on.*", r"advertisement", r"sponsored content", + r"promoted by.*", r"recommended for you", r"© \d{4}.*", + r"all rights reserved", r"terms of service", r"privacy policy", + r"cookie policy", r"about us", r"contact us", r"share this article", + r"sign up for our newsletter", r"report this ad", + r"this story was originally published.*", r"originally appeared on.*", + r"download our app.*", r"view comments", r"comment below", + r"leave a comment", r"next article", r"previous article", + r"related articles", r"top stories", r"breaking news", + r"editor's picks", r"latest news", r"trending now", + r"this content is provided by.*", r"image source:.*", r"photo by.*", + r"disclaimer:.*", r"support independent journalism.*", + r"if you enjoyed this article.*", r"don't miss out on.*", + r"watch the video", r"listen to the podcast", + r"stay connected with.*", r"visit our homepage.*", + r"post a job on.*", r"powered by .*", ] + for pattern in boilerplate_phrases: text = re.sub(pattern, "", text, flags=re.IGNORECASE) - # 3. Remove lines with too few characters (likely junk) lines = text.split("\n") cleaned_lines = [line.strip() for line in lines if len(line.strip()) > 30] - - # 4. Join lines back with a double newline for paragraphs cleaned_text = "\n\n".join(cleaned_lines) - - # 5. Optional: Fix multiple spaces and trim cleaned_text = re.sub(r"[ \t]{2,}", " ", cleaned_text).strip() - - return cleaned_text + return cleaned_text \ No newline at end of file diff --git a/backend/app/modules/scraper/extractor.py b/backend/app/modules/scraper/extractor.py index 15fe0199..898147dc 100644 --- a/backend/app/modules/scraper/extractor.py +++ b/backend/app/modules/scraper/extractor.py @@ -1,21 +1,3 @@ -""" -extractor.py ------------- -Module for extracting article content from a given URL using multiple -progressively robust methods. Attempts extraction with the following -approaches in order: - 1. Trafilatura - 2. Newspaper3k - 3. BeautifulSoup + Readability - -If one method fails, it falls back to the next until a valid article -body is found. - -Classes: - ArticleExtractor - Encapsulates all extraction methods and fallback logic. -""" - import trafilatura from newspaper import Article from bs4 import BeautifulSoup @@ -24,31 +6,27 @@ import logging import json -# This class contains extractors that are more and more advanced from top to -# bottom and they will try to extract any article. - class Article_extractor: - def __init__(self, url): + def __init__(self, url: str): self.url = url self.headers = { "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - " AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/113.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0 Safari/537.36" ) } - def _fetch_html(self): + def _fetch_html(self) -> str: try: - res = requests.get(self.url, self.headers, timeout=10) + res = requests.get(self.url, headers=self.headers, timeout=10) res.raise_for_status() return res.text except requests.RequestException as e: - logging.error(f"failed to fetch: {self.url}-{e}") + logging.error(f"Failed to fetch: {self.url} — {e}") return "" - def extract_with_trafilatura(self): + def extract_with_trafilatura(self) -> dict: downloaded = trafilatura.fetch_url(self.url) if not downloaded: return {} @@ -78,14 +56,13 @@ def extract_with_newspaper(self) -> dict: ), } except Exception as e: - logging.error(f"Newspaper3k failed: {e}") + logging.error(f"Newspaper failed: {e}") return {} def extract_with_bs4(self) -> dict: html = self._fetch_html() if not html: return {} - try: doc = Document(html) soup = BeautifulSoup(doc.summary(), "html.parser") @@ -96,7 +73,7 @@ def extract_with_bs4(self) -> dict: logging.error(f"BS4 + Readability fallback failed: {e}") return {} - def extract(self): + def extract(self) -> dict: methods = [ self.extract_with_trafilatura, self.extract_with_newspaper, @@ -107,4 +84,4 @@ def extract(self): if result and result.get("text"): result["url"] = self.url return result - return {"url": self.url, "text": "", "error": "Failed to extract article."} + return {"url": self.url, "text": "", "error": "Failed to extract article."} \ No newline at end of file diff --git a/backend/app/modules/scraper/keywords.py b/backend/app/modules/scraper/keywords.py index 4d8cdc53..7f2f8f29 100644 --- a/backend/app/modules/scraper/keywords.py +++ b/backend/app/modules/scraper/keywords.py @@ -1,62 +1,31 @@ -""" -keywords.py ------------ -Module for extracting key phrases from text using the RAKE -(Rapid Automatic Keyword Extraction) algorithm. This utility -helps identify the most relevant and representative words or -phrases in a body of text, often useful for summarization, -tagging, search indexing, and content analysis. - -Functions: - extract_keywords(text: str, max_keywords: int = 15) - Runs the RAKE algorithm on the provided text and returns - the top-ranked keywords or phrases up to the specified limit. - - extract_keyword_data(text: str) -> Dict - Higher-level helper function that packages extracted - keywords along with the top phrase and the total count - into a single dictionary for convenient downstream use. -""" - - -from rake_nltk import Rake -from typing import Dict - - -def extract_keywords(text: str, max_keywords: int = 15): - """ - Extracts important keywords from the input text using RAKE algorithm. - - Args: - text (str): The cleaned article text. - max_keywords (int): Max number of keywords to return. - - Returns: - List[str]: A list of important keywords/phrases. - """ - rake = Rake() - rake.extract_keywords_from_text(text) - keywords_with_scores = rake.get_ranked_phrases_with_scores() - - # Sort and limit - keywords = [phrase for score, phrase in sorted(keywords_with_scores, reverse=True)] - return keywords[:max_keywords] - - -def extract_keyword_data(text: str) -> Dict: - """ - High-level utility to package all keyword-related data. - - Returns: - Dict: { - "keywords": [...], - "top_phrase": "...", - "count": N - } - """ - keywords = extract_keywords(text) - return { - "keywords": keywords, - "top_phrase": keywords[0] if keywords else None, - "count": len(keywords), - } +import re +from collections import Counter + +_STOP_WORDS = frozenset({ + "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "as", "is", "was", "are", "were", "been", + "be", "have", "has", "had", "do", "does", "did", "will", "would", + "could", "should", "may", "might", "shall", "can", "need", "dare", + "it", "its", "this", "that", "these", "those", "he", "she", "they", + "we", "you", "me", "him", "her", "us", "them", "my", "your", + "his", "our", "their", "not", "no", "nor", "if", "then", "than", + "so", "such", "very", "too", "also", "just", "about", "above", + "after", "again", "all", "any", "because", "before", "being", + "below", "between", "both", "during", "each", "few", "further", + "get", "got", "here", "how", "into", "more", "most", "much", + "must", "new", "now", "off", "old", "once", "only", "other", + "out", "over", "own", "per", "same", "some", "still", "there", + "through", "under", "until", "upon", "what", "when", "where", + "which", "while", "who", "whom", "why", "yet", "said", "like", + "one", "two", "many", "way", "even", "back", "well", "also", +}) + + +def extract_keywords(text: str, max_keywords: int = 15) -> list[str]: + """Extract top keywords by frequency (no external NLP libraries).""" + if not text: + return [] + words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower()) + filtered = [w for w in words if w not in _STOP_WORDS] + freq = Counter(filtered) + return [word for word, _ in freq.most_common(max_keywords)] \ No newline at end of file diff --git a/backend/app/modules/vector_store/chunk_rag_data.py b/backend/app/modules/vector_store/chunk_rag_data.py index 7e2a32eb..6bcc99e9 100644 --- a/backend/app/modules/vector_store/chunk_rag_data.py +++ b/backend/app/modules/vector_store/chunk_rag_data.py @@ -1,102 +1,89 @@ """ chunk_rag_data.py ----------------- -Module for converting processed article data into smaller, structured -chunks suitable for storage and retrieval in a vector database. - -The chunking process: - 1. Validates the presence of required top-level fields such as - cleaned_text, perspective, and facts. - 2. Assigns a unique article ID to all chunks using a hash-based - generator. - 3. Creates a "counter-perspective" chunk containing the alternative - viewpoint and its reasoning. - 4. Splits each fact into its own chunk, including metadata like - verdict, explanation, and source link. - -This structure enables more efficient semantic search, targeted -retrieval, and fine-grained analysis. - -Functions: - chunk_rag_data(data: dict) -> list[dict] - Validates and transforms the input data into a list of - chunk dictionaries containing text and metadata. +Converts the LangGraph analysis state into embeddable chunks +for Pinecone vector storage. """ - +from typing import Any from app.utils.generate_chunk_id import generate_id from app.logging.logging_config import setup_logger logger = setup_logger(__name__) -def chunk_rag_data(data): - try: - # Validate required top-level fields - required_fields = ["cleaned_text", "perspective", "facts"] - for field in required_fields: - if field not in data: - raise ValueError(f"Missing required field: {field}") - - if not isinstance(data["facts"], list): - raise ValueError("Facts must be a list") - - # Validate perspective structure - perspective_data = data["perspective"] - if hasattr(perspective_data, "dict"): - perspective_data = perspective_data.dict() - - article_id = generate_id(data["cleaned_text"]) - chunks = [] - - # Add counter-perspective chunk - perspective_obj = data["perspective"] - - # Optional safety check - - if not ( - hasattr(perspective_obj, "perspective") - and hasattr(perspective_obj, "reasoning") - ): - raise ValueError("Perspective object missing required fields") - - chunks.append( - { - "id": f"{article_id}-perspective", - "text": perspective_obj.perspective, - "metadata": { - "type": "counter-perspective", - "reasoning": perspective_obj.reasoning, - "article_id": article_id, - }, - } - ) - - # Add each fact as a separate chunk - for i, fact in enumerate(data["facts"]): - fact_fields = ["original_claim", "verdict", "explanation", "source_link"] - for field in fact_fields: - if field not in fact: - raise ValueError( - f"Missing required fact field: {field} in fact index {i}" - ) +def chunk_rag_data(state: dict) -> tuple[list[dict[str, Any]], str | None]: + """Return ``(chunks, error_message | None)``. + Each chunk is a dict with keys ``id``, ``text``, and ``metadata``. + """ + try: + chunks: list[dict[str, Any]] = [] + + # --- Perspective chunk ------------------------------------------- + perspective_obj = state.get("perspective") + if perspective_obj: + if hasattr(perspective_obj, "perspective"): + p_text = perspective_obj.perspective + elif isinstance(perspective_obj, dict): + p_text = perspective_obj.get("perspective", "") + else: + p_text = str(perspective_obj) + + if p_text: + chunks.append( + { + "id": generate_id(f"perspective-{p_text[:60]}"), + "text": p_text, + "metadata": { + "type": "perspective", + "sentiment": state.get("sentiment", ""), + "score": state.get("score", 0), + }, + } + ) + + # --- Summary chunk ----------------------------------------------- + summary = state.get("article_summary", "") + if summary: chunks.append( { - "id": f"{article_id}-fact-{i}", - "text": fact["original_claim"], + "id": generate_id(f"summary-{summary[:60]}"), + "text": summary, "metadata": { - "type": "fact", - "verdict": fact["verdict"], - "explanation": fact["explanation"], - "source_link": fact["source_link"], - "article_id": article_id, + "type": "summary", + "sentiment": state.get("sentiment", ""), }, } ) - return chunks + # --- Fact chunks ------------------------------------------------- + for idx, fact in enumerate(state.get("facts", [])): + claim = fact.get("claim", "") + reason = fact.get("reason", "") + status = fact.get("status", "Unknown") + if claim: + fact_text = ( + f"Claim: {claim}. " + f"Verdict: {status}. " + f"Reason: {reason}" + ) + chunks.append( + { + "id": generate_id(f"fact-{idx}-{claim[:40]}"), + "text": fact_text, + "metadata": { + "type": "fact", + "claim": claim, + "status": status, + "reasoning": reason, + }, + } + ) + + logger.info(f"Created {len(chunks)} chunks for vector storage.") + return chunks, None except Exception as e: - logger.exception(f"Failed to chunk the data: {e}") - raise + logger.exception(f"Error chunking data: {e}") + return [], str(e) \ No newline at end of file diff --git a/backend/app/routes/routes.py b/backend/app/routes/routes.py index 6988f5e8..4daea349 100644 --- a/backend/app/routes/routes.py +++ b/backend/app/routes/routes.py @@ -1,85 +1,105 @@ -""" -routes.py ---------- -Defines the FastAPI API routes for the Perspective application, exposing endpoints -for bias detection, article processing, and chat-based querying over stored RAG data. - -Endpoints: - GET / - Health check endpoint confirming the API is live. - - POST /bias - Accepts a URL, scrapes and processes the article content, and runs bias detection - to return a bias score and related insights. - - POST /process - Accepts a URL, scrapes and processes the article content, then executes the - LangGraph workflow for sentiment analysis, fact-checking, perspective generation, - and final result assembly. - - POST /chat - Accepts a user query, searches stored vector data in Pinecone, and queries an LLM - to produce a contextual answer. - -Core Components: - - run_scraper_pipeline: Extracts and cleans article text, then identifies keywords. - - run_langgraph_workflow: Executes the LangGraph pipeline for deep content analysis. - - check_bias: Scores and analyzes potential bias in article content. - - search_pinecone: Retrieves relevant RAG data for a given query. - - ask_llm: Generates a natural language answer using retrieved context. -""" - - from fastapi import APIRouter from pydantic import BaseModel -from app.modules.pipeline import run_scraper_pipeline -from app.modules.pipeline import run_langgraph_workflow +from app.modules.pipeline import run_scraper_pipeline, run_langgraph_workflow from app.modules.bias_detection.check_bias import check_bias -from app.modules.chat.get_rag_data import search_pinecone -from app.modules.chat.llm_processing import ask_llm +from app.modules.chat.chat_graph import send_chat_message from app.logging.logging_config import setup_logger import asyncio import json logger = setup_logger(__name__) - router = APIRouter() -class URlRequest(BaseModel): +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + +class URLRequest(BaseModel): url: str +class ProcessRequest(BaseModel): + """Accepts a URL *and* the LLM provider the user has chosen (BYOK).""" + url: str + provider: str = "groq" + + class ChatQuery(BaseModel): message: str + thread_id: str + provider: str = "groq" +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + @router.get("/") async def home(): return {"message": "Perspective API is live!"} @router.post("/bias") -async def bias_detection(request: URlRequest): - content = await asyncio.to_thread(run_scraper_pipeline, (request.url)) - bias_score = await asyncio.to_thread(check_bias, (content)) - logger.info(f"Bias detection result: {bias_score}") - return bias_score +async def bias_detection(request: URLRequest): + content = await asyncio.to_thread(run_scraper_pipeline, request.url) + bias_result = await asyncio.to_thread(check_bias, content.get("cleaned_text", "")) + logger.info(f"Bias detection result: {bias_result}") + return bias_result @router.post("/process") -async def run_pipelines(request: URlRequest): - article_text = await asyncio.to_thread(run_scraper_pipeline, (request.url)) - logger.debug(f"Scraper output: {json.dumps(article_text, indent=2, ensure_ascii=False)}") - data = await asyncio.to_thread(run_langgraph_workflow, (article_text)) - return data +async def run_pipelines(request: ProcessRequest): + """Run the full analysis pipeline. + + The ``provider`` field (``"groq"`` or ``"gemini"``) is forwarded to + every LLM call inside the LangGraph workflow so the user's chosen + model is used throughout. + """ + article_data = await asyncio.to_thread(run_scraper_pipeline, request.url) + logger.debug( + f"Scraper output: {json.dumps(article_data, indent=2, ensure_ascii=False)}" + ) + + result = await run_langgraph_workflow(article_data, provider=request.provider) + + # Normalise the perspective object for JSON serialisation + perspective_obj = result.get("perspective") + if hasattr(perspective_obj, "model_dump"): + perspective_data = perspective_obj.model_dump(by_alias=True) + elif hasattr(perspective_obj, "dict"): + perspective_data = perspective_obj.dict() + elif isinstance(perspective_obj, dict): + perspective_data = perspective_obj + else: + perspective_data = {"perspective": str(perspective_obj)} + + return { + "thread_id": result.get("thread_id", ""), + "article_summary": result.get("article_summary", ""), + "web_search_citations": result.get("web_search_citations", []), + "sentiment": result.get("sentiment", ""), + "perspective": perspective_data, + "facts": result.get("facts", []), + "score": result.get("score", 0), + "status": result.get("status", "unknown"), + } @router.post("/chat") async def answer_query(request: ChatQuery): - query = request.message - results = search_pinecone(query) - answer = ask_llm(query, results) - logger.info(f"Chat answer generated: {answer}") - - return {"answer": answer} + """Send a follow-up message within an existing analysis thread. + + The ``provider`` field allows the user to switch models mid-conversation. + """ + try: + answer = await send_chat_message( + thread_id=request.thread_id, + message=request.message, + provider=request.provider, + ) + logger.info(f"Chat response for thread {request.thread_id}") + return {"answer": answer, "thread_id": request.thread_id} + except Exception as e: + logger.exception(f"Chat error: {e}") + return {"error": str(e), "thread_id": request.thread_id} \ No newline at end of file diff --git a/backend/app/utils/fact_check_utils.py b/backend/app/utils/fact_check_utils.py index e7cc7a62..cdfc76a8 100644 --- a/backend/app/utils/fact_check_utils.py +++ b/backend/app/utils/fact_check_utils.py @@ -44,13 +44,17 @@ def run_fact_check_pipeline(state): result = run_claim_extractor_sdk(state) - if state.get("status") != "success": + if result.get("status") != "success": logger.error("❌ Claim extraction failed.") return [], "Claim extraction failed." # Step 1: Extract claims raw_output = result.get("verifiable_claims", "") claims = re.findall(r"^[\*\-•]\s+(.*)", raw_output, re.MULTILINE) + + if not claims and raw_output: + claims = [line.strip() for line in raw_output.split('\n') if len(line.strip()) > 10] + claims = [claim.strip() for claim in claims if claim.strip()] logger.info(f"🧠 Extracted claims: {claims}") @@ -60,7 +64,7 @@ def run_fact_check_pipeline(state): # Step 2: Search each claim with polite delay search_results = [] for claim in claims: - logger.info(f"\n🔍 Searching for claim: {claim}") + logger.info(f"\nSearching for claim: {claim}") try: results = search_google(claim) if results: diff --git a/backend/app/utils/prompt_templates.py b/backend/app/utils/prompt_templates.py index f8caaa8b..934fe746 100644 --- a/backend/app/utils/prompt_templates.py +++ b/backend/app/utils/prompt_templates.py @@ -1,59 +1,18 @@ -""" -prompt_templates.py -------------------- -Houses reusable prompt templates for LLM-based processing tasks within -the pipeline. These templates define structured, instructive contexts -for generating consistent and high-quality responses from language models. - -Variables: - generation_prompt (ChatPromptTemplate) - - A LangChain ChatPromptTemplate configured to produce a - well-reasoned counter-perspective to an article. - - Inputs: - cleaned_article (str): The main text of the article. - sentiment (str): The detected sentiment of the article. - facts (list): Verified factual information related to the article. - - Output: - LLM is instructed to return a JSON object containing: - - "counter_perspective": Opposite viewpoint to the article. - - "reasoning_steps": Step-by-step reasoning sequence. - -Usage: - This prompt ensures responses are logical, respectful, and grounded - in evidence, making it suitable for perspective analysis, debate - generation, and bias exploration tasks. -""" - - from langchain.prompts import ChatPromptTemplate -generation_prompt = ChatPromptTemplate.from_template(""" -You are an AI assistant that generates a well-reasoned ' -'counter-perspective to a given article. +generation_prompt = ChatPromptTemplate.from_template( + """You are an AI assistant that generates a well-reasoned counter-perspective \ +to a given article. -## Article: +Article: {cleaned_article} -## Sentiment: +Sentiment: {sentiment} -## Verified Facts: +Verified Facts: {facts} ---- - -Generate a logical and respectful *opposite perspective* to the article. -Use *step-by-step reasoning* and return your output in this JSON format: - -```json -{{ - "counter_perspective": "", - "reasoning_steps": [ - "", - "", - "", - "...", - "" - ] -}} -""") +Generate a logical, respectful opposite perspective to the article. +Use step-by-step reasoning and provide a catchy short title (max 10 words).""" +) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 6f4c4552..e2608317 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,18 +19,21 @@ app (FastAPI): The FastAPI application instance. """ +from dotenv import load_dotenv +load_dotenv() # load env vars before any other module reads them + from fastapi import FastAPI from app.routes.routes import router as article_router from fastapi.middleware.cors import CORSMiddleware from app.logging.logging_config import setup_logger - -# Setup logger for this module +import os + logger = setup_logger(__name__) app = FastAPI( title="Perspective API", - version="1.0.0", - description=("An API to generate alternative perspectives on biased articles"), + version="2.0.0", + description="An API to generate alternative perspectives on biased articles", ) app.add_middleware( @@ -45,8 +48,7 @@ if __name__ == "__main__": import uvicorn - import os port = int(os.environ.get("PORT", 7860)) - logger.info(f" Server is running on http://localhost:{port}") - uvicorn.run(app, host="0.0.0.0", port=port) + logger.info(f"Server is running on http://localhost:{port}") + uvicorn.run(app, host="0.0.0.0", port=port) \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 70037f72..396f06ca 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,17 +9,15 @@ dependencies = [ "dotenv>=0.9.9", "duckduckgo-search>=8.0.4", "fastapi>=0.115.12", - "google-search-results>=2.4.2", "groq>=0.28.0", "langchain>=0.3.25", "langchain-community>=0.3.25", + "langchain-google-genai>=2.1.12", "langchain-groq>=0.3.2", "langgraph>=0.4.8", - "logging>=0.4.9.6", - "newspaper3k>=0.2.8", - "nltk>=3.9.1", + "newspaper4k>=0.9.4.1", #should remove this in next PR as this is not needed "pinecone>=7.3.0", - "rake-nltk>=1.0.6", + "python-dotenv>=1.1.0", "readability-lxml>=0.8.4.1", "requests>=2.32.3", "sentence-transformers>=5.0.0", diff --git a/backend/uv.lock b/backend/uv.lock index fc1e19b5..b5ed4627 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 2 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "aiohappyeyeballs" @@ -106,17 +110,15 @@ dependencies = [ { name = "dotenv" }, { name = "duckduckgo-search" }, { name = "fastapi" }, - { name = "google-search-results" }, { name = "groq" }, { name = "langchain" }, { name = "langchain-community" }, + { name = "langchain-google-genai" }, { name = "langchain-groq" }, { name = "langgraph" }, - { name = "logging" }, - { name = "newspaper3k" }, - { name = "nltk" }, + { name = "newspaper4k" }, { name = "pinecone" }, - { name = "rake-nltk" }, + { name = "python-dotenv" }, { name = "readability-lxml" }, { name = "requests" }, { name = "sentence-transformers" }, @@ -130,17 +132,15 @@ requires-dist = [ { name = "dotenv", specifier = ">=0.9.9" }, { name = "duckduckgo-search", specifier = ">=8.0.4" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "google-search-results", specifier = ">=2.4.2" }, { name = "groq", specifier = ">=0.28.0" }, { name = "langchain", specifier = ">=0.3.25" }, { name = "langchain-community", specifier = ">=0.3.25" }, + { name = "langchain-google-genai", specifier = ">=2.1.12" }, { name = "langchain-groq", specifier = ">=0.3.2" }, { name = "langgraph", specifier = ">=0.4.8" }, - { name = "logging", specifier = ">=0.4.9.6" }, - { name = "newspaper3k", specifier = ">=0.2.8" }, - { name = "nltk", specifier = ">=3.9.1" }, + { name = "newspaper4k", specifier = ">=0.9.4.1" }, { name = "pinecone", specifier = ">=7.3.0" }, - { name = "rake-nltk", specifier = ">=1.0.6" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "readability-lxml", specifier = ">=0.8.4.1" }, { name = "requests", specifier = ">=2.32.3" }, { name = "sentence-transformers", specifier = ">=5.0.0" }, @@ -161,6 +161,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "bs4" version = "0.0.2" @@ -270,6 +298,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, +] + [[package]] name = "cssselect" version = "1.3.0" @@ -355,27 +418,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, ] -[[package]] -name = "feedfinder2" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/82/1251fefec3bb4b03fd966c7e7f7a41c9fc2bb00d823a34c13f847fd61406/feedfinder2-0.0.4.tar.gz", hash = "sha256:3701ee01a6c85f8b865a049c30ba0b4608858c803fe8e30d1d289fdbe89d0efe", size = 3297, upload-time = "2016-01-25T15:09:17.492Z" } - [[package]] name = "feedparser" -version = "6.0.11" +version = "6.0.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sgmllib3k" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/aa/7af346ebeb42a76bf108027fe7f3328bb4e57a3a96e53e21fd9ef9dd6dd0/feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5", size = 286197, upload-time = "2023-12-10T16:03:20.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/d4/8c31aad9cc18f451c49f7f9cfb5799dadffc88177f7917bc90a66459b1d7/feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45", size = 81343, upload-time = "2023-12-10T16:03:19.484Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, ] [[package]] @@ -387,6 +439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -440,13 +501,68 @@ wheels = [ ] [[package]] -name = "google-search-results" -version = "2.4.2" +name = "google-ai-generativelanguage" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/f0/d999b2ef7e6d59c3b17d61eaf01f80889cf88d04899115584c2a5e512260/google_ai_generativelanguage-0.10.0.tar.gz", hash = "sha256:17e998094003a566e0fa52249fdd49e8f4c030cebe7fe0c521b40d605aba783e", size = 1524340, upload-time = "2026-01-15T13:14:46.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/71/124c4f3f5685ec64d3bf984a21be702397a1fbfa00a2c03efe7f75bd5b2d/google_ai_generativelanguage-0.10.0-py3-none-any.whl", hash = "sha256:b6ebcb7c9e51848097901fb6a75375ca8f957538e7918d055ffeb8076fbc537a", size = 1416801, upload-time = "2026-01-09T14:52:31.833Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/30/b3a6f6a2e00f8153549c2fa345c58ae1ce8e5f3153c2fe0484d444c3abcb/google_search_results-2.4.2.tar.gz", hash = "sha256:603a30ecae2af8e600b22635757a6df275dad4b934f975e67878ccd640b78245", size = 18818, upload-time = "2023-03-10T11:13:09.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] [[package]] name = "greenlet" @@ -489,6 +605,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/24/20fc18d1b3e0883aeb24286ca8f26dc1970561b07d9c4412c84561bdf307/groq-0.28.0-py3-none-any.whl", hash = "sha256:c6f86638371c2cba2ca337232e76c8d412e75965ed7e3058d30c9aa5dfe84303", size = 130217, upload-time = "2025-06-12T16:22:47.97Z" }, ] +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -594,12 +755,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "jieba3k" -version = "0.35.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/cb/2c8332bcdc14d33b0bedd18ae0a4981a069c3513e445120da3c3f23a8aaa/jieba3k-0.35.1.zip", hash = "sha256:980a4f2636b778d312518066be90c7697d410dd5a472385f5afced71a2db1c10", size = 7423646, upload-time = "2014-11-15T05:47:47.978Z" } - [[package]] name = "jinja2" version = "3.1.6" @@ -697,7 +852,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "0.3.65" +version = "0.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -707,10 +862,26 @@ dependencies = [ { name = "pyyaml" }, { name = "tenacity" }, { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a4/24f2d787bfcf56e5990924cacefe6f6e7971a3629f97c8162fc7a2a3d851/langchain_core-0.3.83.tar.gz", hash = "sha256:a0a4c7b6ea1c446d3b432116f405dc2afa1fe7891c44140d3d5acca221909415", size = 597965, upload-time = "2026-01-13T01:19:23.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/db/d71b80d3bd6193812485acea4001cdf86cf95a44bbf942f7a240120ff762/langchain_core-0.3.83-py3-none-any.whl", hash = "sha256:8c92506f8b53fc1958b1c07447f58c5783eb8833dd3cb6dc75607c80891ab1ae", size = 458890, upload-time = "2026-01-13T01:19:21.748Z" }, +] + +[[package]] +name = "langchain-google-genai" +version = "2.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "google-ai-generativelanguage" }, + { name = "langchain-core" }, + { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/8a/d08c83195d1ef26c42728412ab92ab08211893906b283abce65775e21327/langchain_core-0.3.65.tar.gz", hash = "sha256:54b5e0c8d9bb405415c3211da508ef9cfe0acbe5b490d1b4a15664408ee82d9b", size = 558557, upload-time = "2025-06-10T20:08:28.94Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/38/8b3a71c729bd03e9eb0fd8bdb19e06a074c35bc2eaa61b1b9edfa863f38d/langchain_google_genai-2.1.12.tar.gz", hash = "sha256:4a98371e545eb97fcdf483086a4aebbb8eceeb9597ca5a9c4c35e92f4fbbd271", size = 77566, upload-time = "2025-09-17T01:27:11.747Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/f0/31db18b7b8213266aed926ce89b5bdd84ccde7ee2edf4cab14e3dd2bfcf1/langchain_core-0.3.65-py3-none-any.whl", hash = "sha256:80e8faf6e9f331f8ef728f3fe793549f1d3fb244fcf9e1bdcecab6a6f4669394", size = 438052, upload-time = "2025-06-10T20:08:27.393Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8d/9dd9653e5414e73cae3480e5947bbbbd94ba7fa824efdf46e7ff2c0faef2/langchain_google_genai-2.1.12-py3-none-any.whl", hash = "sha256:4c07630419a8fbe7a2ec512c6dea68289663bfe7d5fae0ba431d2cd59a0d0880", size = 50746, upload-time = "2025-09-17T01:27:10.653Z" }, ] [[package]] @@ -760,7 +931,7 @@ name = "langgraph-checkpoint" version = "2.0.26" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core", marker = "python_full_version < '4.0'" }, + { name = "langchain-core", marker = "python_full_version < '4'" }, { name = "ormsgpack" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/61/e2518ac9216a4e9f4efda3ac61595e3c9e9ac00833141c9688e8d56bd7eb/langgraph_checkpoint-2.0.26.tar.gz", hash = "sha256:2b800195532d5efb079db9754f037281225ae175f7a395523f4bf41223cbc9d6", size = 37874, upload-time = "2025-05-15T17:31:22.466Z" } @@ -812,12 +983,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/f4/c206c0888f8a506404cb4f16ad89593bdc2f70cf00de26a1a0a7a76ad7a3/langsmith-0.3.45-py3-none-any.whl", hash = "sha256:5b55f0518601fa65f3bb6b1a3100379a96aa7b3ed5e9380581615ba9c65ed8ed", size = 363002, upload-time = "2025-06-05T05:10:27.228Z" }, ] -[[package]] -name = "logging" -version = "0.4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/4b/979db9e44be09f71e85c9c8cfc42f258adfb7d93ce01deed2788b2948919/logging-0.4.9.6.tar.gz", hash = "sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417", size = 96029, upload-time = "2013-06-04T23:43:22.086Z" } - [[package]] name = "lxml" version = "5.4.0" @@ -973,27 +1138,26 @@ wheels = [ ] [[package]] -name = "newspaper3k" -version = "0.2.8" +name = "newspaper4k" +version = "0.9.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, - { name = "cssselect" }, - { name = "feedfinder2" }, + { name = "brotli" }, { name = "feedparser" }, - { name = "jieba3k" }, - { name = "lxml" }, + { name = "lxml", extra = ["html-clean"] }, { name = "nltk" }, - { name = "pillow" }, + { name = "pillow", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pillow", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "python-dateutil" }, { name = "pyyaml" }, { name = "requests" }, - { name = "tinysegmenter" }, { name = "tldextract" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/fb/8f8525be0cafa48926e85b0c06a7cb3e2a892d340b8036f8c8b1b572df1c/newspaper3k-0.2.8.tar.gz", hash = "sha256:9f1bd3e1fb48f400c715abf875cc7b0a67b7ddcd87f50c9aeeb8fcbbbd9004fb", size = 205685, upload-time = "2018-09-28T04:58:23.53Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/cc/cf743a3d06b10907cec76a675fe0857445907ded0e64ec4624483c1467ce/newspaper4k-0.9.4.1.tar.gz", hash = "sha256:5b1a92dfb04d6d379f9484fad4ad44741deb9ac3d55a6c178badf2a0d4bba903", size = 3971874, upload-time = "2025-11-18T06:08:56.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/b9/51afecb35bb61b188a4b44868001de348a0e8134b4dfa00ffc191567c4b9/newspaper3k-0.2.8-py3-none-any.whl", hash = "sha256:44a864222633d3081113d1030615991c3dbba87239f6bbf59d91240f71a22e3e", size = 211132, upload-time = "2018-09-28T04:58:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/7e6c6a6e626fec2ec25f7b69fa0c446d1ca8a7fad121b61f2672e5779b76/newspaper4k-0.9.4.1-py3-none-any.whl", hash = "sha256:fab18fdb0637da0ea2452e18c5986c4af2263ba3016ff684f1c522f696bd39bd", size = 306153, upload-time = "2025-11-18T06:08:54.9Z" }, ] [[package]] @@ -1226,6 +1390,9 @@ wheels = [ name = "pillow" version = "11.2.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14'", +] sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" }, @@ -1252,6 +1419,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +] + [[package]] name = "pinecone" version = "7.3.0" @@ -1262,7 +1490,7 @@ dependencies = [ { name = "pinecone-plugin-interface" }, { name = "python-dateutil" }, { name = "typing-extensions" }, - { name = "urllib3", marker = "python_full_version < '4.0'" }, + { name = "urllib3", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/38/12731d4af470851b4963eba616605868a8599ef4df51c7b6c928e5f3166d/pinecone-7.3.0.tar.gz", hash = "sha256:307edc155621d487c20dc71b76c3ad5d6f799569ba42064190d03917954f9a7b", size = 235256, upload-time = "2025-06-27T20:03:51.498Z" } wheels = [ @@ -1348,6 +1576,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1461,18 +1737,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] -[[package]] -name = "rake-nltk" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nltk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/b1/53392b9ba76fdb1e9de3198f63eb1cb92529c80201e0709162d140134b30/rake-nltk-1.0.6.tar.gz", hash = "sha256:7813d680b2ce77b51cdac1757f801a87ff47682c9dbd2982aea3b66730346122", size = 9089, upload-time = "2021-09-15T05:13:18.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/e5/18876d587142df57b1c70ef752da34664bb7dd383710ccf3ccaefba2aa0c/rake_nltk-1.0.6-py3-none-any.whl", hash = "sha256:1c1ffdb64cae8cb99d169d53a5ffa4635f1c4abd3a02c6e22d5d083136bdc5c1", size = 9103, upload-time = "2021-09-15T05:13:19.937Z" }, -] - [[package]] name = "readability-lxml" version = "0.8.4.1" @@ -1527,14 +1791,14 @@ wheels = [ [[package]] name = "requests-file" -version = "2.1.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/97/bf44e6c6bd8ddbb99943baf7ba8b1a8485bcd2fe0e55e5708d7fee4ff1ae/requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658", size = 6891, upload-time = "2024-05-21T16:28:00.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/25/dd878a121fcfdf38f52850f11c512e13ec87c2ea72385933818e5b6c15ce/requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c", size = 4244, upload-time = "2024-05-21T16:27:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, ] [[package]] @@ -1549,6 +1813,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "safetensors" version = "0.5.3" @@ -1629,7 +1905,8 @@ version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, - { name = "pillow" }, + { name = "pillow", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pillow", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "scikit-learn" }, { name = "scipy" }, { name = "torch" }, @@ -1747,12 +2024,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] -[[package]] -name = "tinysegmenter" -version = "0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/82/86982e4b6d16e4febc79c2a1d68ee3b707e8a020c5d2bc4af8052d0f136a/tinysegmenter-0.3.tar.gz", hash = "sha256:ed1f6d2e806a4758a73be589754384cbadadc7e1a414c81a166fc9adf2d40c6d", size = 16893, upload-time = "2017-07-23T11:18:29.85Z" } - [[package]] name = "tld" version = "0.13.1" @@ -1968,6 +2239,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, +] + [[package]] name = "uvicorn" version = "0.34.3" diff --git a/frontend/app/analyze/loading/page.tsx b/frontend/app/analyze/loading/page.tsx deleted file mode 100644 index 05067e9c..00000000 --- a/frontend/app/analyze/loading/page.tsx +++ /dev/null @@ -1,299 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Card } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Globe, - Brain, - Shield, - CheckCircle, - Database, - Sparkles, - Zap, -} from "lucide-react"; -import ThemeToggle from "@/components/theme-toggle"; -import axios from "axios"; - -// const backend_url = process.env.NEXT_PUBLIC_API_URL; - - - -/** - * Displays a multi-step animated loading and progress interface for the article analysis workflow. - * - * Guides the user through sequential analysis steps—fetching the article, AI analysis, bias detection, fact checking, and generating perspectives—while visually indicating progress and status. Retrieves the article URL from session storage, automatically advances through each step, and redirects to the results page upon completion. If no article URL is found, redirects to the analysis input page. - * - * @remark This component manages its own navigation and redirects based on session state. - */ -export default function LoadingPage() { - const [currentStep, setCurrentStep] = useState(0); - const [progress, setProgress] = useState(0); - const [articleUrl, setArticleUrl] = useState(""); - const router = useRouter(); - - const steps = [ - { - icon: Globe, - title: "Fetching Article", - description: "Retrieving content from the provided URL", - color: "from-blue-500 to-cyan-500", - }, - { - icon: Brain, - title: "AI Analysis", - description: "Processing content with advanced NLP algorithms", - color: "from-purple-500 to-indigo-500", - }, - { - icon: Shield, - title: "Bias Detection", - description: "Identifying potential biases and one-sided perspectives", - color: "from-emerald-500 to-teal-500", - }, - { - icon: CheckCircle, - title: "Fact Checking", - description: "Cross-referencing claims with reliable sources", - color: "from-orange-500 to-red-500", - }, - { - icon: Database, - title: "Generating Perspectives", - description: "Creating balanced alternative viewpoints", - color: "from-pink-500 to-rose-500", - }, - ]; - - useEffect(() => { - const runAnalysis = async () => { - const storedUrl = sessionStorage.getItem("articleUrl"); - if (storedUrl) { - setArticleUrl(storedUrl); - - try { - const [processRes, biasRes] = await Promise.all([ - axios.post("https://thunder1245-perspective-backend.hf.space/api/process", { - url: storedUrl, - }), - axios.post("https://thunder1245-perspective-backend.hf.space/api/bias", { - url: storedUrl, - }), - ]); - - sessionStorage.setItem("BiasScore", JSON.stringify(biasRes.data)); - - console.log("Bias score saved"); - console.log(biasRes); - - // Save response to sessionStorage - sessionStorage.setItem( - "analysisResult", - JSON.stringify(processRes.data) - ); - - console.log("Analysis result saved"); - console.log(processRes); - - // optional logging - } catch (err) { - console.error("Failed to process article:", err); - router.push("/analyze"); // fallback in case of error - return; - } - - // Progress and step simulation - const stepInterval = setInterval(() => { - setCurrentStep((prev) => { - if (prev < steps.length - 1) { - return prev + 1; - } else { - clearInterval(stepInterval); - setTimeout(() => { - router.push("/analyze/results"); - }, 2000); - return prev; - } - }); - }, 2000); - - const progressInterval = setInterval(() => { - setProgress((prev) => { - if (prev < 100) { - return prev + 1; - } - return prev; - }); - }, 100); - - return () => { - clearInterval(stepInterval); - clearInterval(progressInterval); - }; - } else { - router.push("/analyze"); - } - }; - - runAnalysis(); - }, [router]); - - return ( -
- {/* Animated background elements */} -
-
-
-
-
- - {/* Header */} -
-
-
router.push("/")} - > -
- -
- - Perspective - -
-
- -
-
-
- - {/* Main Content */} -
-
- {/* Status Badge */} - - - AI Processing in Progress - - - {/* Main Title */} -

- Analyzing Your Article -

- - {/* Article URL Display */} -
-

- Processing: -

-

- {articleUrl} -

-
- - {/* Progress Bar */} -
-
-
-
-
-
-

- {Math.min(progress, (currentStep + 1) * 20)}% Complete -

-
- - {/* Processing Steps */} -
- {steps.map((step, index) => ( - -
-
- {index < currentStep ? ( - - ) : index === currentStep ? ( - - ) : ( - - )} -
-
-

- {step.title} -

-

- {step.description} -

-
- {index === currentStep && ( -
-
-
-
-
- )} -
-
- ))} -
- - {/* AI Processing Animation */} -
-
-
-
-
-
- -
-
-
- -

- Our AI is working hard to provide you with comprehensive analysis... -

-
-
-
- ); -} diff --git a/frontend/app/analyze/page.tsx b/frontend/app/analyze/page.tsx deleted file mode 100644 index c86c6c9e..00000000 --- a/frontend/app/analyze/page.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; - -import type React from "react"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Globe, - ArrowRight, - Link, - Sparkles, - Shield, - Brain, - CheckCircle, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import ThemeToggle from "@/components/theme-toggle"; - -/** - * Renders the main page for submitting an article URL to initiate AI-powered analysis. - * - * Provides a user interface for entering and validating an article URL, displays real-time feedback on URL validity, and enables users to trigger analysis. Features include a branded header, a hero section, a URL input card with validation, a grid highlighting analysis capabilities, and example article URLs for quick testing. On valid submission, the URL is stored in sessionStorage and the user is navigated to a loading page for further processing. - */ -export default function AnalyzePage() { - const [url, setUrl] = useState(""); - const [isValidUrl, setIsValidUrl] = useState(false); - const router = useRouter(); - - const validateUrl = (inputUrl: string) => { - try { - new URL(inputUrl); - setIsValidUrl(true); - } catch { - setIsValidUrl(false); - } - }; - - const handleUrlChange = (e: React.ChangeEvent) => { - const inputUrl = e.target.value; - setUrl(inputUrl); - if (inputUrl.length > 0) { - validateUrl(inputUrl); - } else { - setIsValidUrl(false); - } - }; - - const handleAnalyze = () => { - if (isValidUrl && url) { - // Store the URL in sessionStorage to pass to loading page - sessionStorage.setItem("articleUrl", url); - router.push("/analyze/loading"); - } - }; - - const features = [ - { - icon: Brain, - title: "AI Analysis", - description: "Advanced NLP extracts key points and arguments", - }, - { - icon: Shield, - title: "Bias Detection", - description: "Identifies potential biases and one-sided perspectives", - }, - { - icon: CheckCircle, - title: "Fact Verification", - description: "Cross-references claims with reliable sources", - }, - ]; - - return ( -
- {/* Animated background elements */} -
-
-
-
- - {/* Header */} -
-
-
router.push("/")} - > -
- -
- - Perspective - -
-
- -
-
-
- - {/* Main Content */} -
-
- {/* Hero Section */} -
- - - AI-Powered Analysis - - -

- Analyze Any Article -

- -

- Paste the URL of any online article and get AI-powered bias - detection, fact-checking, and alternative perspectives in seconds. -

-
- - {/* URL Input Section */} - - - - Enter Article URL - - - Provide the link to the article you want to analyze for bias and - alternative perspectives - - - -
-
- - - {url && ( -
- {isValidUrl ? ( - - ) : ( -
-
-
- )} -
- )} -
- -
- {url && !isValidUrl && ( -

- Please enter a valid URL -

- )} -
-
- - {/* Features Grid */} -
- {features.map((feature, index) => ( - - -
- -
- - {feature.title} - -
- - - {feature.description} - - -
- ))} -
- - {/* Example URLs */} - - - - Try These Example Articles - - - -
- {[ - "https://www.bbc.com/news/technology", - "https://www.reuters.com/business/", - "https://www.theguardian.com/world", - ].map((exampleUrl, index) => ( - - ))} -
-
-
-
-
-
- ); -} diff --git a/frontend/app/analyze/results/page.tsx b/frontend/app/analyze/results/page.tsx deleted file mode 100644 index bd484492..00000000 --- a/frontend/app/analyze/results/page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -"use client"; - -import type React from "react"; -import { useState, useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Send, Link as LinkIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import BiasMeter from "@/components/bias-meter"; -import axios from "axios"; - -// const backend_url = process.env.NEXT_PUBLIC_API_URL; - -/** - * Renders the article analysis page with summary, perspectives, fact checks, bias meter, AI chat, and sources. - */ -export default function AnalyzePage() { - const [analysisData, setAnalysisData] = useState(null); - const [biasScore, setBiasScore] = useState(null); - const router = useRouter(); - const isRedirecting = useRef(false); - const [activeTab, setActiveTab] = useState("summary"); - const [message, setMessage] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [messages, setMessages] = useState<{ role: string; content: string }[]>( - [ - { - role: "system", - content: - "Welcome to the Perspective chat. You can ask me questions about this article or request more information about specific claims.", - }, - ] - ); - - useEffect(() => { - const storedBiasScore = sessionStorage.getItem("BiasScore"); - const storedData = sessionStorage.getItem("analysisResult"); - if (storedBiasScore && storedData) { - setIsLoading(false); - } - - if (storedBiasScore) setBiasScore(JSON.parse(storedBiasScore).bias_score); - else console.warn("No bias score found."); - - if (storedData) setAnalysisData(JSON.parse(storedData)); - else console.warn("No analysis result found"); - }, []); - - useEffect(() => { - if (isRedirecting.current) { - return; - } - - const storedData = sessionStorage.getItem("analysisResult"); - const storedBiasScore = sessionStorage.getItem("BiasScore"); - - if (storedBiasScore && storedData) { - // inside here TS knows storedBiasScore and storedData are strings - setBiasScore(JSON.parse(storedBiasScore).bias_score); - setAnalysisData(JSON.parse(storedData)); - setIsLoading(false); - } else { - console.warn("No bias or data found. Redirecting..."); - if (!isRedirecting.current) { - isRedirecting.current = true; - router.push("/analyze"); // 🔹 You can also add a toast here - } - } - }, [router]); - - async function handleSendMessage(e: React.FormEvent) { - e.preventDefault(); - if (!message.trim()) return; - const newMessages = [...messages, { role: "user", content: message }]; - setMessages(newMessages); - setMessage(""); - - const res = await axios.post("https://thunder1245-perspective-backend.hf.space/api/chat", { - message: message, - }); - const data = res.data; - - console.log(data); - - // 🔹 Step 2: Append LLM’s response - setMessages([...newMessages, { role: "assistant", content: data.answer }]); - } - if (isLoading) { - return ( -
-
Analyzing content...
-
- ); - } - - - const { - cleaned_text, - facts = [], - sentiment, - perspective, - score, - } = analysisData; - - - - - - return ( -
- {/* Header omitted for brevity */} -
-
-

Analysis Results

- - Sentiment: {sentiment} - -
-
- -

Bias Score: {biasScore}

-
- -
-
- - - Article - Perspective - Fact Check - - - -
- {cleaned_text - .split("\n\n") - .map((para: string, idx: number) => ( -

{para}

- ))} -
-
- - - {perspective ? ( -
-

- Counter-Perspective -

-

"{perspective.perspective}"

-

Reasoning:

-

{perspective.reasoning}

-
- ) : ( -
- No counter-perspective was generated for this content. -
- )} -
- - -
- {facts.length > 0 ? ( - facts.map((fact: any, idx: number) => ( - - -
- {fact.original_claim} - - {fact.verdict} - -
-
- -

{fact.explanation}

- - Source - -
-
- )) - ) : ( -
- No specific claims were identified for fact-checking in - this content. -
- )} -
-
-
-
- -
- - - AI Discussion - - Ask questions about this article - - - -
- {messages.map((msg, i) => ( -
-
- {msg.content} -
-
- ))} -
-
- setMessage(e.target.value)} - /> - -
-
-
-
-
-
- {/* Footer omitted */} -
- ); -} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 29185903..69e23964 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2,132 +2,23 @@ @tailwind components; @tailwind utilities; -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; - --radius: 0.5rem; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; - } -} - @layer base { * { @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background-dark text-white; } } -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); +@layer utilities { + .hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ } -} - -@keyframes slide-up { - from { - opacity: 0; - transform: translateY(20px); + .hide-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + width: 0; + height: 0; } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slide-in-right { - from { - opacity: 0; - transform: translateX(20px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -.animate-fade-in { - animation: fade-in 0.6s ease-out forwards; - opacity: 0; -} - -.fade-in { - animation: fade-in 0.6s ease-out; -} - -.slide-up { - animation: slide-up 0.6s ease-out; -} - -.slide-in-right { - animation: slide-in-right 0.4s ease-out; -} - -.delay-200 { - animation-delay: 200ms; -} - -.delay-300 { - animation-delay: 300ms; -} - -.delay-400 { - animation-delay: 400ms; -} - -.delay-500 { - animation-delay: 500ms; -} - -.delay-700 { - animation-delay: 700ms; -} - -.delay-1000 { - animation-delay: 1000ms; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 3c5a4677..a072ea4b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,10 +1,9 @@ import type React from "react" import "./globals.css" import type { Metadata } from "next" -import { Inter } from "next/font/google" -import { ThemeProvider } from "@/components/theme-provider" +import { Sora } from "next/font/google" -const inter = Inter({ subsets: ["latin"] }) +const sora = Sora({ subsets: ["latin"] }) export const metadata: Metadata = { title: "Perspective - AI-Powered Bias Detection", @@ -14,7 +13,7 @@ export const metadata: Metadata = { /** * Root layout component that sets up global HTML structure, font, and theming for the application. * - * Wraps all page content with the Inter font and a theme provider supporting system-based theming. + * Wraps all page content with the Sora font. * * @param children - The content to be rendered within the layout. */ @@ -24,11 +23,9 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - - - {children} - + + + {children} ) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index bc1f0d76..7f746915 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,301 +1,22 @@ -"use client"; +import React from "react"; +import Navbar from "@/components/landing/Navbar"; +import HeroSection from "@/components/landing/HeroSection"; +import FeaturesSection from "@/components/landing/FeaturesSection"; +import StatsSection from "@/components/landing/StatsSection"; +import CTASection from "@/components/landing/CTASection"; +import Footer from "@/components/landing/Footer"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Shield, - Brain, - Database, - CheckCircle, - Globe, - ArrowRight, - Sparkles, -} from "lucide-react"; -import ThemeToggle from "@/components/theme-toggle"; - -/** - * Renders the main landing page for the Perspective application, showcasing its features, technology stack, and calls to action. - * - * The page includes animated backgrounds, a header with branding and theme toggle, a hero section with statistics and navigation, sections explaining the app's purpose and workflow, a technology showcase, and a footer with attribution. - * - * @returns The complete landing page React element for the Perspective app. - */ export default function Home() { - const router = useRouter(); - const features = [ - { - icon: Brain, - title: "AI-Powered Analysis", - description: - "Advanced NLP and LangGraph pipeline extracts key points and generates balanced counter-perspectives.", - color: "from-purple-500 to-indigo-600", - }, - { - icon: Shield, - title: "Bias Detection", - description: - "Sophisticated algorithms identify and highlight potential biases in article content.", - color: "from-emerald-500 to-teal-600", - }, - { - icon: CheckCircle, - title: "Fact Checking", - description: - "Cross-references claims with reliable sources to ensure accuracy and credibility.", - color: "from-blue-500 to-cyan-600", - }, - { - icon: Database, - title: "Vector Database", - description: - "Efficient storage and retrieval system enables chat-based exploration of perspectives.", - color: "from-orange-500 to-red-600", - }, - ]; - - const technologies = [ - { name: "Python", color: "bg-gradient-to-r from-blue-500 to-blue-600" }, - { - name: "TypeScript", - color: "bg-gradient-to-r from-blue-600 to-indigo-600", - }, - { - name: "FastAPI", - color: "bg-gradient-to-r from-green-500 to-emerald-600", - }, - { name: "Next.js", color: "bg-gradient-to-r from-gray-700 to-gray-900" }, - { - name: "Tailwind CSS", - color: "bg-gradient-to-r from-cyan-500 to-blue-500", - }, - { - name: "LangChain", - color: "bg-gradient-to-r from-purple-500 to-indigo-600", - }, - { name: "LangGraph", color: "bg-gradient-to-r from-pink-500 to-rose-600" }, - { name: "NLP", color: "bg-gradient-to-r from-amber-500 to-orange-600" }, - { name: "Vector DB", color: "bg-gradient-to-r from-teal-500 to-cyan-600" }, - ]; - - const stats = [ - { label: "Articles Analyzed", value: "10,000+", color: "text-blue-600" }, - { label: "Biases Detected", value: "95%", color: "text-emerald-600" }, - { label: "Fact Accuracy", value: "98%", color: "text-purple-600" }, - { label: "User Satisfaction", value: "4.9/5", color: "text-orange-600" }, - ]; - return ( -
- {/* Animated background elements */} -
-
-
-
- - {/* Header */} -
-
-
-
- -
- - Perspective - -
-
- -
-
-
- - {/* Hero Section */} -
-
- - - AI-Powered Bias Detection - - -

- Uncover Hidden - - Perspectives - -

- -

- Combat bias and one-sided narratives with AI-generated alternative - perspectives. Get fact-based, balanced viewpoints on any online - article through our advanced NLP pipeline powered by LangGraph and - LangChain. -

- - -

- No sign in required. It’s completely free. -

- - {/* Floating stats */} -
- {stats.map((stat, index) => ( -
-
- {stat.value} -
-
- {stat.label} -
-
- ))} -
-
-
- - {/* What is Perspective Section */} -
-
-

- What is Perspective? -

-

- Perspective addresses the critical problem of biased and one-sided - narratives in online articles. Our AI-powered solution provides - readers with fact-based, well-structured alternative perspectives by - analyzing article content, extracting key points, and generating - logical counter-perspectives using cutting-edge natural language - processing technology. -

-
-
- - {/* How It Works Section */} -
-
-

- How Perspective Works -

-

- Our advanced AI pipeline processes articles through multiple stages - to deliver balanced, fact-checked perspectives. -

-
- -
- {features.map((feature, index) => ( - - -
- -
- - {feature.title} - -
- - - {feature.description} - - -
- ))} -
-
- - {/* Technology Stack */} -
- -
-
-
-

- Built with Cutting-Edge Technology -

-

- Powered by the latest in AI, NLP, and web technologies to - deliver accurate, fast, and reliable perspective analysis. -

-
- -
- {technologies.map((tech, index) => ( - - {tech.name} - - ))} -
-
-
-
- - {/* CTA Section */} -
-
-

- Ready to See Every Side of the Story? -

-

- Join thousands of readers who are already discovering balanced - perspectives and combating bias in online content. -

- -
-
- - {/* Footer */} -
-
-
-
-
- -
- - Perspective - - - by AOSSIE - -
-
- © 2024 AOSSIE. Combating bias through AI-powered perspective - analysis. -
-
-
-
+
+ +
+ + + + +
+
); } diff --git a/frontend/app/perspective/page.tsx b/frontend/app/perspective/page.tsx new file mode 100644 index 00000000..2f512608 --- /dev/null +++ b/frontend/app/perspective/page.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { + Settings, + Sparkles, + MessageSquarePlus, + ChevronLeft, + ChevronRight, + Send, + Menu, + X, + ChevronDown, + Check, + Bot, + User, + Loader2, +} from "lucide-react"; + +import { usePerspective } from "@/hooks/use-perspective"; +import { useChat } from "@/hooks/use-chat"; +import { RightSidebar } from "@/components/perspective/RightSidebar"; + +const PROVIDERS = [ + { id: "groq", name: "Groq" }, + { id: "gemini", name: "Gemini" }, +]; + +export default function PerspectivePage() { + const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); + const [rightSidebarOpen, setRightSidebarOpen] = useState(true); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const { + analysisData, + loading, + biasScore, + scoreConfig, + provider, + setProvider, + } = usePerspective(); + + const threadId = analysisData?.thread_id; + const { messages, sendMessage, sending } = useChat(threadId); + + const [chatInput, setChatInput] = useState(""); + const [providerOpen, setProviderOpen] = useState(false); + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const title = + analysisData?.perspective?.short_title || "Analyzing Article..."; + const perspectiveText = + analysisData?.perspective?.perspective || + "Please wait while our AI agents analyze the content, check facts, and determine the perspective..."; + const summary = analysisData?.article_summary; + + const handleSend = () => { + if (!chatInput.trim() || sending) return; + sendMessage(chatInput, provider); + setChatInput(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const currentProvider = PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0]; + + return ( +
+ {/* ---- Mobile Header ---- */} +
+ + perspective + + +
+ + {/* ---- Left Sidebar ---- */} + + + {/* ---- Main Content ---- */} +
+
+ {/* Title */} +
+ {loading.process ? ( +
+ ) : ( +

+ {title} +

+ )} +
+ + {/* Article Summary */} + {summary && !loading.process && ( +
+

+ Article Summary +

+

{summary}

+
+ )} + + {/* Perspective Analysis */} +
+ +
+

+ Perspective Analysis +

+
+

{perspectiveText}

+
+
+
+ + {/* Web Search Citations (inline) */} + {analysisData?.web_search_citations && + analysisData.web_search_citations.length > 0 && ( +
+

+ Sources Used +

+
    + {analysisData.web_search_citations.map((c, i) => ( +
  • + + {c.title || c.url} + + {c.snippet && ( +

    + {c.snippet} +

    + )} +
  • + ))} +
+
+ )} + + {/* Chat Messages */} + {messages.length > 0 && ( +
+

+ Conversation +

+ {messages.map((msg, idx) => ( +
+ {msg.role === "assistant" && ( +
+ +
+ )} +
+

{msg.content}

+
+ {msg.role === "user" && ( +
+ +
+ )} +
+ ))} + + {sending && ( +
+
+ +
+
+ +
+
+ )} + +
+
+ )} +
+ + {/* ---- Input Area ---- */} +
+
+ {/* Provider Dropdown */} +
+ + + {providerOpen && ( +
+ {PROVIDERS.map((p) => ( + + ))} +
+ )} +
+ + {/* Text Input */} +
+ setChatInput(e.target.value)} + onKeyDown={handleKeyDown} + disabled={!threadId || loading.process} + placeholder={ + loading.process + ? "Waiting for analysis to complete..." + : !threadId + ? "Analysis required before chatting..." + : "Ask questions about this article..." + } + className="w-full bg-[#1B1F24] border border-white/10 rounded-lg px-4 py-3 pr-12 text-white placeholder-gray-500 focus:outline-none focus:border-white/30 font-sora transition-all shadow-sm disabled:opacity-50" + /> + +
+
+
+
+ + {/* ---- Right Sidebar ---- */} + setRightSidebarOpen(!rightSidebarOpen)} + loading={loading.bias} + biasScore={biasScore} + scoreConfig={scoreConfig} + summary={analysisData?.article_summary} + facts={analysisData?.facts} + citations={analysisData?.web_search_citations} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/assets/BiasDetectionBG.png b/frontend/assets/BiasDetectionBG.png new file mode 100644 index 00000000..02861101 Binary files /dev/null and b/frontend/assets/BiasDetectionBG.png differ diff --git a/frontend/assets/DeepResearchBG.png b/frontend/assets/DeepResearchBG.png new file mode 100644 index 00000000..9801533c Binary files /dev/null and b/frontend/assets/DeepResearchBG.png differ diff --git a/frontend/assets/FactCheckBG.png b/frontend/assets/FactCheckBG.png new file mode 100644 index 00000000..565810d1 Binary files /dev/null and b/frontend/assets/FactCheckBG.png differ diff --git a/frontend/assets/OwnKeysBG.png b/frontend/assets/OwnKeysBG.png new file mode 100644 index 00000000..5aee635a Binary files /dev/null and b/frontend/assets/OwnKeysBG.png differ diff --git a/frontend/assets/chatai.svg b/frontend/assets/chatai.svg new file mode 100644 index 00000000..0b3f83b7 --- /dev/null +++ b/frontend/assets/chatai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/assets/dropdown.svg b/frontend/assets/dropdown.svg new file mode 100644 index 00000000..8a29fea7 --- /dev/null +++ b/frontend/assets/dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/newchaticon.svg b/frontend/assets/newchaticon.svg new file mode 100644 index 00000000..45c966b2 --- /dev/null +++ b/frontend/assets/newchaticon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/assets/sendbtn.svg b/frontend/assets/sendbtn.svg new file mode 100644 index 00000000..1073563c --- /dev/null +++ b/frontend/assets/sendbtn.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/settingsicon.svg b/frontend/assets/settingsicon.svg new file mode 100644 index 00000000..eca0fb37 --- /dev/null +++ b/frontend/assets/settingsicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/components/bias-meter.tsx b/frontend/components/bias-meter.tsx deleted file mode 100644 index df460453..00000000 --- a/frontend/components/bias-meter.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -interface BiasMeterProps { - score: number; -} - -/** - * Displays a circular progress meter visualizing a bias score with color-coded feedback and descriptive labeling. - * - * Renders the given score as a circular progress indicator, where the color and label reflect the bias level: green for low, yellow for moderate, and red for high bias. - * - * @param score - The bias score to display, expected to be between 0 and 100. - */ -export default function BiasMeter({ score }: BiasMeterProps) { - -if(!score){ - score = 0 -} - - const getScoreColor = (score: number) => { - if (score <= 30) return "text-green-500"; - if (score <= 60) return "text-yellow-500"; - return "text-red-500"; - }; - - const getScoreLabel = (score: number) => { - if (score <= 30) return "Low Bias"; - if (score <= 60) return "Moderate Bias"; - return "High Bias"; - }; - - const circumference = 2 * Math.PI * 45; - const strokeDasharray = circumference; - const strokeDashoffset = circumference - (score / 100) * circumference; - - return ( -
-
-

Bias Score

- - {score}/100 - -
-
-
- - - - -
- - {score} - -
-
-
-

- {getScoreLabel(score)} -

-
- ); -} diff --git a/frontend/components/landing/Button.tsx b/frontend/components/landing/Button.tsx new file mode 100644 index 00000000..7bd67e3d --- /dev/null +++ b/frontend/components/landing/Button.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; + +interface ButtonProps { + children: React.ReactNode; + size?: "default" | "large"; + onClick?: () => void; +} + +export default function Button({ children, size = "default", onClick }: ButtonProps) { + const sizeClasses = size === "large" + ? "px-[27.197px] py-[6.217px] text-[18.65px] tracking-[-0.9325px]" + : "px-[35px] py-[8px] text-[24px] tracking-tighter"; + + return ( + + ); +} diff --git a/frontend/components/landing/CTASection.tsx b/frontend/components/landing/CTASection.tsx new file mode 100644 index 00000000..c1a7d31f --- /dev/null +++ b/frontend/components/landing/CTASection.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import Button from "./Button"; + +export default function CTASection() { + return ( +
+
+

+ Ready to See Every Side of the Story? +

+ +

+ Join thousands of readers who are already discovering balanced perspectives and combating bias in online content. +

+
+ +
+ +
+
+ ); +} diff --git a/frontend/components/landing/FeatureCard.tsx b/frontend/components/landing/FeatureCard.tsx new file mode 100644 index 00000000..13eade40 --- /dev/null +++ b/frontend/components/landing/FeatureCard.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import Image, { StaticImageData } from "next/image"; + +interface FeatureCardProps { + image: string | StaticImageData; + title: string; + description: string; +} + +export default function FeatureCard({ + image, + title, + description, +}: FeatureCardProps) { + return ( +
+
+
+ +
+ {title} +
+ +
+

+ {title} +

+

+ {description} +

+
+
+
+ ); +} diff --git a/frontend/components/landing/FeaturesSection.tsx b/frontend/components/landing/FeaturesSection.tsx new file mode 100644 index 00000000..4e948d7b --- /dev/null +++ b/frontend/components/landing/FeaturesSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import FeatureCard from "./FeatureCard"; +import BiasDetectionImg from "@/assets/BiasDetectionBG.png"; +import OwnKeysImg from "@/assets/OwnKeysBG.png"; +import DeepResearchImg from "@/assets/DeepResearchBG.png"; +import FactCheckImg from "@/assets/FactCheckBG.png"; + +const features = [ + { + image: BiasDetectionImg, + title: "Uncover Agendas\nand Leanings", + description: "Instantly analyze the political slant and emotional language of any article.", + }, + { + image: OwnKeysImg, + title: "Bring Your Own Keys,\nYour Privacy, Your Control", + description: "Connect your own API keys. You keep full control over your data usage and billing.", + }, + { + image: DeepResearchImg, + title: "Deep Research,\nDone in seconds", + description: "An intelligent agent that reads, digests, and summarizes multiple viewpoints to give you the complete picture", + }, + { + image: FactCheckImg, + title: "Verify Claims with\nWeb-Search", + description: "Don't trust blindly. Cross-checks article claims against the live internet.", + }, +]; + +export default function FeaturesSection() { + return ( +
+
+

+ The Perspective Engine +

+ +

+ Our advanced AI pipeline processes articles through multiple stages to deliver balanced, fact-checked perspectives. +

+
+ +
+ {features.map((feature, index) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/landing/Footer.tsx b/frontend/components/landing/Footer.tsx new file mode 100644 index 00000000..24410635 --- /dev/null +++ b/frontend/components/landing/Footer.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +export default function Footer() { + return ( +
+

+ perspective +

+ +

+ © 2026 AOSSIE. Combating bias through AI-powered perspective analysis. +

+
+ ); +} diff --git a/frontend/components/landing/HeroSection.tsx b/frontend/components/landing/HeroSection.tsx new file mode 100644 index 00000000..6080fad5 --- /dev/null +++ b/frontend/components/landing/HeroSection.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import SearchBar from "./SearchBar"; + +export default function HeroSection() { + return ( +
+
+

+ Uncover Hidden +
+ Perspectives +

+ +

+ Combat bias and one-sided narratives with AI-researched alternative perspectives. +

+
+ +
+ +
+
+ ); +} diff --git a/frontend/components/landing/Navbar.tsx b/frontend/components/landing/Navbar.tsx new file mode 100644 index 00000000..24fb1922 --- /dev/null +++ b/frontend/components/landing/Navbar.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import Button from "./Button"; + +export default function Navbar() { + return ( +
+ +
+ ); +} diff --git a/frontend/components/landing/SearchBar.tsx b/frontend/components/landing/SearchBar.tsx new file mode 100644 index 00000000..2420bbe9 --- /dev/null +++ b/frontend/components/landing/SearchBar.tsx @@ -0,0 +1,154 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ChevronDown, Check, AlertCircle } from "lucide-react"; + +const providers = [ + { id: "gemini", name: "Gemini" }, + { id: "groq", name: "Groq" }, +]; + +export default function SearchBar() { + const router = useRouter(); + const [url, setUrl] = useState(""); + const [isValidUrl, setIsValidUrl] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(providers[0]); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const validateUrl = (inputUrl: string) => { + try { + new URL(inputUrl); + return true; + } catch { + return false; + } + }; + + const handleUrlChange = (e: React.ChangeEvent) => { + const inputUrl = e.target.value; + setUrl(inputUrl); + if (inputUrl.length > 0) { + setIsValidUrl(validateUrl(inputUrl)); + } else { + setIsValidUrl(false); + } + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (isValidUrl && url) { + + sessionStorage.removeItem("analysisResult"); + sessionStorage.removeItem("BiasScore"); + + sessionStorage.setItem("articleUrl", url); + sessionStorage.setItem("selectedProvider", selectedProvider.id); + + router.push("/perspective"); + } + }; + + return ( +
+
+ {/* Input Field */} + + + {/* Validation Indicator */} + {url && ( +
+ {isValidUrl ? ( + + ) : ( + + )} +
+ )} + + {/* Divider */} +
+ + {/* Provider Dropdown Trigger */} +
+ + + {isDropdownOpen && ( +
+ {providers.map((provider) => ( + + ))} +
+ )} +
+ + {/* Search Icon Button */} + +
+ + {/* Error message */} + {url && !isValidUrl && ( +

+ Please enter a valid URL +

+ )} +
+ ); +} diff --git a/frontend/components/landing/StatsSection.tsx b/frontend/components/landing/StatsSection.tsx new file mode 100644 index 00000000..ecba7fd7 --- /dev/null +++ b/frontend/components/landing/StatsSection.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface StatProps { + value: string; + label: string; +} + +function Stat({ value, label }: StatProps) { + return ( +
+

+ {value} +

+

+ {label} +

+
+ ); +} + +export default function StatsSection() { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/components/perspective/BiasGauge.tsx b/frontend/components/perspective/BiasGauge.tsx new file mode 100644 index 00000000..162b2b9a --- /dev/null +++ b/frontend/components/perspective/BiasGauge.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +interface BiasGaugeProps { + score: number; + gradientColors: string[]; + textColor: string; + label: string; +} + +export function BiasGauge({ score, gradientColors, textColor, label }: BiasGaugeProps) { + return ( +
+
+ + {/* Background Track */} + + {/* Progress Track */} + + + + + + + + +
+
+
{Math.round(score)}%
+
{label}
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/perspective/RightSidebar.tsx b/frontend/components/perspective/RightSidebar.tsx new file mode 100644 index 00000000..436dfb28 --- /dev/null +++ b/frontend/components/perspective/RightSidebar.tsx @@ -0,0 +1,225 @@ +import React, { useState } from "react"; +import { + ChevronRight, + ChevronLeft, + ChevronDown, + Loader2, + ExternalLink, + CheckCircle2, + XCircle, + HelpCircle, +} from "lucide-react"; +import { BiasGauge } from "./BiasGauge"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Fact { + claim?: string; + status?: string; + reason?: string; +} + +interface Citation { + title?: string; + url?: string; + snippet?: string; +} + +interface RightSidebarProps { + isOpen: boolean; + onToggle: () => void; + loading: boolean; + biasScore: number; + scoreConfig: { text: string; gradient: string[]; label: string }; + summary?: string; + facts?: Fact[]; + citations?: Citation[]; +} + +/* ------------------------------------------------------------------ */ +/* Sidebar */ +/* ------------------------------------------------------------------ */ + +export function RightSidebar({ + isOpen, + onToggle, + loading, + biasScore, + scoreConfig, + summary, + facts, + citations, +}: RightSidebarProps) { + const [sections, setSections] = useState({ + bias: true, + summary: false, + facts: false, + citations: false, + }); + + const toggleSection = (key: keyof typeof sections) => { + setSections((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const factStatusIcon = (status?: string) => { + const s = (status || "").toLowerCase(); + if (s === "true") return ; + if (s === "false") return ; + return ; + }; + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Reusable accordion item */ +/* ------------------------------------------------------------------ */ + +function AccordionItem({ + title, + isOpen, + onToggle, + children, +}: { + title: string; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx deleted file mode 100644 index dafaf396..00000000 --- a/frontend/components/theme-provider.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client' - -import * as React from 'react' -import { - ThemeProvider as NextThemesProvider, - type ThemeProviderProps, -} from 'next-themes' - -/** - * Provides theme context to its child components using the next-themes provider. - * - * Wraps children with theme management capabilities, enabling dynamic theme switching throughout the application. - * - * @param children - React nodes to receive theme context. - */ -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children} -} diff --git a/frontend/components/theme-toggle.tsx b/frontend/components/theme-toggle.tsx deleted file mode 100644 index 9d2a3b32..00000000 --- a/frontend/components/theme-toggle.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client" - -import * as React from "react" -import { Moon, Sun } from "lucide-react" -import { useTheme } from "next-themes" -import { Button } from "@/components/ui/button" - -/** - * Renders a button that toggles between light and dark themes. - * - * Displays a Sun or Moon icon depending on the current theme, with smooth transitions and accessible labeling. - * The button is only interactive after the component has mounted to ensure correct theme detection on the client. - */ -export function ThemeToggle() { - const { setTheme, theme } = useTheme() - const [mounted, setMounted] = React.useState(false) - - React.useEffect(() => { - setMounted(true) - }, []) - - if (!mounted) { - return ( - - ) - } - - return ( - - ) -} - -export default ThemeToggle diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx deleted file mode 100644 index 24c788c2..00000000 --- a/frontend/components/ui/accordion.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client" - -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Accordion = AccordionPrimitive.Root - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)) - -AccordionContent.displayName = AccordionPrimitive.Content.displayName - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx deleted file mode 100644 index 25e7b474..00000000 --- a/frontend/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client" - -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -const AlertDialog = AlertDialogPrimitive.Root - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName - -const AlertDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" - -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx deleted file mode 100644 index 41fa7e05..00000000 --- a/frontend/components/ui/alert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)) -Alert.displayName = "Alert" - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertTitle.displayName = "AlertTitle" - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertDescription.displayName = "AlertDescription" - -export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/components/ui/aspect-ratio.tsx b/frontend/components/ui/aspect-ratio.tsx deleted file mode 100644 index d6a5226f..00000000 --- a/frontend/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client" - -import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" - -const AspectRatio = AspectRatioPrimitive.Root - -export { AspectRatio } diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx deleted file mode 100644 index 51e507ba..00000000 --- a/frontend/components/ui/avatar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" - -import { cn } from "@/lib/utils" - -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Avatar.displayName = AvatarPrimitive.Root.displayName - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx deleted file mode 100644 index d57592df..00000000 --- a/frontend/components/ui/badge.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -/** - * Renders a styled badge element with configurable visual variants. - * - * @param variant - Specifies the badge style. Options include "default", "secondary", "destructive", and "outline". - */ -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} - -export { Badge, badgeVariants } diff --git a/frontend/components/ui/breadcrumb.tsx b/frontend/components/ui/breadcrumb.tsx deleted file mode 100644 index 60e6c96f..00000000 --- a/frontend/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode - } ->(({ ...props }, ref) =>