Skip to content

Conversation

@yugborana
Copy link

@yugborana yugborana commented Feb 8, 2026

Summary

This PR improves the Perspective API performance and reliability by:

  1. Replacing Google Custom Search with DuckDuckGo - No API key required, cost-free, and more reliable
  2. Adding an in-memory response caching layer - Reduces redundant LLM calls and provides instant responses for repeated URLs
  3. Converting the LangGraph pipeline to async - Enabling parallel execution of sentiment analysis and fact-checking

Key Changes

🦆 DuckDuckGo Fact-Checking (app/modules/fact_check_tool.py)

  • New 4-stage pipeline: extract_claimsplan_searchesexecute_searchesverify_facts
  • Uses langchain_community.tools.DuckDuckGoSearchRun for free web searches
  • Parallel search execution with asyncio.gather
  • Token-optimized prompts to avoid Groq rate limits

🚀 Response Caching (app/cache/cache.py)

  • In-memory OrderedDict cache with TTL (24 hours default)
  • Thread-safe with LRU eviction strategy
  • Cache key: SHA256 hash of endpoint + url
  • Configuration via environment variables: CACHE_ENABLED, CACHE_TTL_SECONDS, CACHE_MAX_SIZE

⚡ Async Parallel Pipeline

  • run_parallel_analysis() runs sentiment and fact-checking concurrently
  • LangGraph workflow uses ainvoke() for non-blocking execution
  • ~40% faster processing time

📁 Cache Hit Auto-Export

  • Cache hits automatically save JSON responses to backend/cache_responses/
  • Useful for debugging and offline analysis

🐛 Bug Fixes

  • Fixed headers argument bug in extractor.py (line 44)
  • Updated generate_perspective.py and chunk_rag_data.py to handle new fact format

Performance Results

Metric Before After
First request ~15-20s ~6-8s
Repeated request (cache hit) ~15-20s ~7ms

Testing

  • ✅ Verified cache miss → cache hit flow
  • ✅ Verified DuckDuckGo searches return results
  • ✅ Verified Pinecone upserting works
  • ✅ Verified JSON auto-export on cache hit

Related PRs

Summary by CodeRabbit

  • New Features

    • Response caching for API endpoints with TTL and LRU eviction; admin endpoints to view stats, clear cache, and delete entries.
    • New web-backed fact-checking pipeline that extracts claims, runs searches, and verifies facts.
  • Improvements

    • Parallel sentiment + fact-check analysis for faster results.
    • Updated LLM model selection for improved accuracy.
    • Better handling of varied fact data formats; core workflows now run asynchronously.
  • Bug Fixes

    • More resilient behavior when facts are missing or incomplete.

- Replace Google Custom Search with DuckDuckGo for fact-checking
- Add in-memory response caching with TTL (24h default)
- Implement parallel sentiment/fact-check analysis
- Convert LangGraph pipeline to async (ainvoke)
- Add cache hit auto-save to local JSON files
- Fix compatibility for new fact format in generate_perspective and chunk_rag_data
- Fix scraper headers bug in extractor.py
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 8, 2026

Warning

Rate limit exceeded

@yugborana has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 1 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Introduces an in-memory TTL LRU cache and route cache endpoints, adds a DuckDuckGo-backed async fact-checking pipeline, refactors LangGraph to run sentiment and fact-check analysis in parallel, updates multiple LLM model identifiers, and makes the LangGraph invocation asynchronous.

Changes

Cohort / File(s) Summary
Cache Infrastructure
backend/app/cache/__init__.py, backend/app/cache/cache.py
New thread-safe in-memory URLCache with TTL, LRU eviction, env-configurable settings, CacheEntry structure, and get_cache() singleton; cache singleton re-exported.
Route Integration & Admin
backend/app/routes/routes.py
Adds cache checks/writes to bias/process endpoints, local JSON persistence for cached responses, and admin endpoints: GET /cache/stats, DELETE /cache/clear, DELETE /cache/{endpoint} (admin-key protected).
Fact-Checking Pipeline
backend/app/modules/fact_check_tool.py
New async 4-step DuckDuckGo-based fact-check pipeline: claim extraction, search planning, parallel search execution, and verification; uses Groq LLM client and DuckDuckGo search runner.
LangGraph Orchestration
backend/app/modules/langgraph_builder.py, backend/app/modules/langgraph_nodes/sentiment.py, backend/app/modules/langgraph_nodes/generate_perspective.py
Refactors workflow to run sentiment and fact-checking in parallel via run_parallel_analysis; expands state (claims/search_queries/search_results); relaxes facts parsing and missing-facts handling.
LLM Model Updates
backend/app/modules/bias_detection/check_bias.py, backend/app/modules/chat/llm_processing.py, backend/app/modules/facts_check/llm_processing.py, backend/app/modules/langgraph_nodes/judge.py, backend/app/modules/langgraph_nodes/sentiment.py
Replaces model id gemma2-9b-it with llama-3.3-70b-versatile in multiple Groq LLM calls.
Pipeline Async Call
backend/app/modules/pipeline.py
Makes run_langgraph_workflow asynchronous and awaits the LangGraph ainvoke call.
Data Parsing & Scraper
backend/app/modules/vector_store/chunk_rag_data.py, backend/app/modules/scraper/extractor.py
Flexible fact field parsing (claim/original_claim, status/verdict, reason/explanation) with defaults; minor readability change to requests.get call.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Routes as routes.py
    participant Cache as URLCache
    participant Pipeline as pipeline.py
    participant LangGraph as LangGraph
    participant Sentiment as sentiment.py
    participant FactCheck as fact_check_tool.py
    participant LLM as Groq LLM

    Client->>Routes: POST /bias or /process (state)
    Routes->>Cache: get(endpoint, url)
    alt Cache Hit
        Cache-->>Routes: cached response + _cache metadata
        Routes-->>Client: 200 (cached)
    else Cache Miss
        Routes->>Pipeline: await run_langgraph_workflow(state)
        Pipeline->>LangGraph: ainvoke(state)
        par Parallel analysis
            LangGraph->>Sentiment: run_parallel_analysis(state)
            rect rgba(100,150,255,0.5)
                Sentiment->>LLM: sentiment analysis request
                LLM-->>Sentiment: sentiment result
            end
            rect rgba(100,200,100,0.5)
                LangGraph->>FactCheck: run fact-check pipeline
                FactCheck->>FactCheck: extract_claims -> plan_searches
                FactCheck->>FactCheck: execute DuckDuckGo searches (parallel)
                FactCheck->>LLM: verify facts
                LLM-->>FactCheck: verification results
                FactCheck-->>LangGraph: facts + search_results
            end
        end
        LangGraph-->>Pipeline: merged results
        Pipeline-->>Routes: workflow result
        Routes->>Cache: set(endpoint, url, response)
        Routes-->>Client: 200 (fresh)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Ms-Error

Poem

"🐇 I cached a thought beneath the moon,
I chased wild claims with DuckDuckGo tune,
Parallel hops, LLMs hum and play,
Responses warmed in TTL hay —
A rabbit cheers the pipeline's new day!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely summarizes the two main changes: DuckDuckGo-based fact-checking and response caching. It matches the PR objectives perfectly.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/app/modules/facts_check/llm_processing.py (2)

143-148: ⚠️ Potential issue | 🔴 Critical

Bug: parsed is undefined or stale when JSON parsing fails.

If json.loads fails, the except block only logs and doesn't assign a fallback to parsed. On the first iteration this causes an UnboundLocalError; on subsequent iterations it silently appends the previous iteration's result again.

🐛 Proposed fix
             try:
                 parsed = json.loads(content)
             except Exception as parse_err:
                 logger.error(f"LLM JSON parse error: {parse_err}")
+                parsed = {
+                    "verdict": "Unknown",
+                    "explanation": f"Failed to parse LLM response: {parse_err}",
+                    "original_claim": claim,
+                    "source_link": source,
+                }
 
             results_list.append(parsed)

150-154: ⚠️ Potential issue | 🟠 Major

Bug: claim on Line 151 only reflects the last iteration's value.

After the for loop, claim holds the value from the final iteration. If search_results contains multiple claims, the returned dictionary misrepresents which claim was processed. Additionally, if search_results is empty, claim is undefined and this will raise a NameError.

🐛 Proposed fix — return all claims or remove the misleading key
         return {
-            "claim": claim,
             "verifications": results_list,
             "status": "success",
         }
🤖 Fix all issues with AI agents
In `@backend/app/cache/cache.py`:
- Around line 139-160: The set() method in cache.py is storing the caller's
value by reference which allows external mutation to corrupt cached entries;
update set(endpoint, url, value) to store a deep copy of value (e.g., via
copy.deepcopy) when creating the CacheEntry so the cache is isolated from caller
mutations; locate the set method and change the line that assigns
self._cache[key] = CacheEntry(value, self._ttl_seconds) to use a deep-copied
value, keeping the rest of the eviction and key-generation logic (see
_generate_key, CacheEntry, and get for context).

In `@backend/app/modules/fact_check_tool.py`:
- Around line 140-147: The loop that processes LLM results in fact_check_tool.py
assumes item["claim_id"] is an int and uses c_id < len(claims), which can raise
TypeError if the LLM returns a string; update the code around where c_id is set
(the loop building context) to coerce c_id = int(item.get("claim_id")) inside a
small try/except (ValueError/TypeError), skip the item if conversion fails, and
only perform the bounds check against len(claims) after successful conversion;
also preserve the existing evidence handling (item.get('result')[:300] or 'No
evidence') so invalid claim_id values do not cause the entire verification to
silently fail.

In `@backend/app/routes/routes.py`:
- Around line 114-121: The cached dict is mutated after storing
(bias_score["_cache"] = ...) which corrupts the cached entry; fix by making
cache.set in cache.py store a deep copy of the value (and likewise return a deep
copy from cache.get if it returns references) so callers like the routes
functions (where bias_score is passed to cache.set) cannot mutate the stored
object; update cache.set to import copy and call copy.deepcopy(value) before
persisting and ensure cache.get returns a deepcopy as well.
- Around line 71-99: Add bounded retention to cache files by introducing a
MAX_CACHE_FILES constant and pruning oldest files before writing in
_save_cache_response; compute url_hash/filename as now, then list
CACHE_RESPONSES_DIR/*.json (use file mtime) and if count >= MAX_CACHE_FILES
delete the oldest files until under the limit (perform file operations in
asyncio.to_thread to avoid blocking), then write the new JSON as before; keep
json_serializer and logging unchanged and ensure deletions are logged and
exceptions handled.
- Around line 159-190: Protect the destructive cache endpoints by adding an auth
check: modify cache_clear and cache_delete to require an admin/API key or
internal-only access before performing operations; e.g., add a FastAPI
dependency or explicit header check (validate an "X-API-Key" or bearer token)
and return 401/403 if invalid, or alternatively verify request.client.host is
localhost if you opt for internal-only access. Update router signatures for
cache_clear and cache_delete to accept the auth dependency (or request for host
check), perform the credential validation early, and only call cache.clear() or
cache.delete() when the check passes; log unauthorized attempts in the same
functions (cache_clear, cache_delete) and keep cache_stats read-only if you want
it public.
🧹 Nitpick comments (9)
backend/app/modules/pipeline.py (1)

41-41: Unused asyncio import.

asyncio is imported but not used anywhere in this file — ainvoke is a method on the LangGraph workflow object.

Proposed fix
-import asyncio
backend/app/modules/langgraph_nodes/generate_perspective.py (1)

59-71: Flexible fact parsing looks good, but note inconsistency with chunk_rag_data.py.

This file uses f.get('claim', f.get('original_claim', 'Unknown')) (key-existence fallback), while chunk_rag_data.py uses fact.get("claim") or fact.get("original_claim") or "Unknown claim" (truthiness-based fallback). The two approaches behave differently when a key exists but has an empty/falsy value — get with a default returns "", while the or chain skips to the next fallback.

Consider aligning both files on the same strategy for consistency.

backend/app/modules/langgraph_builder.py (1)

47-57: Minor type annotation inconsistency.

facts uses the built-in list[dict] (Line 49), while the new fields use typing.List[str] / List[Any] (Lines 55–57). Consider using one style consistently — preferably the built-in generics (list[str], list[Any]) since this project targets Python 3.9+.

♻️ Suggested fix
-from typing import List, Any
 from langgraph.graph import StateGraph
 from typing_extensions import TypedDict
 
...

 class MyState(TypedDict):
     cleaned_text: str
     facts: list[dict]
     sentiment: str
     perspective: str
     score: int
     retries: int
     status: str
-    claims: List[str]
-    search_queries: List[Any]
-    search_results: List[Any]
+    claims: list[str]
+    search_queries: list
+    search_results: list
backend/app/cache/cache.py (3)

153-157: Unnecessary eviction when updating an existing key.

If the key already exists in the cache (e.g., re-caching the same URL), the while loop will still evict the oldest entry even though the total count won't increase. Check for existing key first.

♻️ Suggested fix
         with self._lock:
-            # Evict oldest entries if at max size
-            while len(self._cache) >= self._max_size:
-                evicted_key, _ = self._cache.popitem(last=False)
-                logger.debug(f"Evicted cache entry: {evicted_key}")
+            # Evict oldest entries if at max size (only if this is a new key)
+            if key not in self._cache:
+                while len(self._cache) >= self._max_size:
+                    evicted_key, _ = self._cache.popitem(last=False)
+                    logger.debug(f"Evicted cache entry: {evicted_key}")
             
             self._cache[key] = CacheEntry(value, self._ttl_seconds)

216-225: Singleton get_cache() is not thread-safe.

Two threads calling get_cache() concurrently before any import of __init__.py could each create a separate instance. In practice this is likely fine since the module-level cache = get_cache() in __init__.py runs at import time (single-threaded), but adding a lock would make this robust.

♻️ Suggested fix
 _cache_instance: Optional[URLCache] = None
+_cache_lock = threading.Lock()
 
 def get_cache() -> URLCache:
     """Get or create the singleton cache instance."""
     global _cache_instance
-    if _cache_instance is None:
-        _cache_instance = URLCache()
+    if _cache_instance is None:
+        with _cache_lock:
+            if _cache_instance is None:
+                _cache_instance = URLCache()
     return _cache_instance

46-56: Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) to support Python 3.12+.

datetime.utcnow() is deprecated since Python 3.12 because it returns a naive datetime object, which can lead to timezone-related bugs. Use datetime.now(timezone.utc) instead to get a timezone-aware UTC datetime.

♻️ Suggested fix
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
...
     def __init__(self, value: Any, ttl_seconds: int):
         self.value = value
-        self.cached_at = datetime.utcnow()
+        self.cached_at = datetime.now(timezone.utc)
         self.expires_at = self.cached_at + timedelta(seconds=ttl_seconds)
     
     def is_expired(self) -> bool:
         """Check if this cache entry has expired."""
-        return datetime.utcnow() > self.expires_at
+        return datetime.now(timezone.utc) > self.expires_at
backend/app/modules/langgraph_nodes/sentiment.py (2)

77-89: If both branches fail, only the sentiment error is surfaced.

The sequential if checks mean that if both sentiment_result and fact_check_result report errors, only the sentiment error is returned. Consider logging the other failure or combining them.

♻️ Possible improvement
+    errors = []
+    if sentiment_result.get("status") == "error":
+        errors.append(("sentiment_analysis", sentiment_result.get("message", "Unknown error")))
+    if fact_check_result.get("status") == "error":
+        errors.append(("fact_checking", fact_check_result.get("message", "Unknown error")))
+    
+    if errors:
+        return {
+            "status": "error",
+            "error_from": errors[0][0],
+            "message": "; ".join(f"[{src}] {msg}" for src, msg in errors)
+        }

62-68: Redundant exception object in logging.exception call.

logger.exception() already includes the traceback and exception info. Passing e in the f-string is redundant.

♻️ Suggested fix
-            logger.exception(f"Error in fact_check_pipeline: {e}")
+            logger.exception("Error in fact_check_pipeline")
backend/app/modules/fact_check_tool.py (1)

66-68: Use logger.exception instead of logger.error in exception handlers for traceback visibility.

All four nodes use logger.error(f"...") in their except blocks. logger.exception() automatically includes the traceback, which is much more useful for debugging production issues.

♻️ Example fix (apply to all four handlers)
     except Exception as e:
-        logger.error(f"Error extracting claims: {e}")
+        logger.exception("Error extracting claims")
         return {"claims": []}

Also applies to: 101-103, 122-124, 184-186

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@backend/app/modules/fact_check_tool.py`:
- Around line 106-129: In execute_searches_node (and its nested run_one_search),
guard against q.get("query") being None before calling search_tool.invoke: check
query_str = q.get("query") and if it's falsy/None, log a warning including the
claim id (c_id from q.get("claim_id")), return a predictable result object
(e.g., {"claim_id": c_id, "result": "No query provided"}) and skip calling
asyncio.to_thread(search_tool.invoke, ...); otherwise proceed with the existing
try/except flow so you never pass None into search_tool.invoke.
- Around line 26-27: The DuckDuckGo search calls lack asyncio-level timeouts and
can hang; wrap each invocation of run_one_search (the function used with
asyncio.to_thread and asyncio.gather) with asyncio.wait_for or pass a timeout to
asyncio.gather so each search has an explicit timeout (e.g.,
asyncio.wait_for(run_one_search(q), timeout=10)). Update the calls around
asyncio.to_thread(...) that invoke run_one_search and the asyncio.gather(...)
call that aggregates multiple run_one_search tasks to enforce a per-call timeout
(or a gather-level timeout) and handle asyncio.TimeoutError where appropriate to
avoid blocking the entire fact-check flow when DuckDuckGoSearchRun hangs.

In `@backend/app/routes/routes.py`:
- Around line 198-222: In cache_delete, when the endpoint is not one of
["process","bias"] (see function cache_delete and parameter endpoint), replace
the current plain JSON return with raising an HTTPException(status_code=400,
detail="Invalid endpoint. Use 'process' or 'bias'") so the handler returns a
proper 400 Bad Request instead of an implicit 200; keep the error text
consistent with other HTTPException usage in the function.
🧹 Nitpick comments (2)
backend/app/routes/routes.py (2)

80-93: Blocking file I/O in async function.

_cleanup_old_cache_files calls .glob(), .stat(), and .unlink() synchronously. Since it's async and called from the request path (via _save_cache_response), this blocks the event loop. Wrap the heavy work in asyncio.to_thread like you already do for the file write in _save_cache_response.

♻️ Proposed fix
 async def _cleanup_old_cache_files() -> None:
     """Remove oldest cache files if exceeding max limit."""
     try:
-        files = list(CACHE_RESPONSES_DIR.glob("*.json"))
-        if len(files) > CACHE_RESPONSES_MAX_FILES:
-            # Sort by modification time (oldest first)
-            files.sort(key=lambda f: f.stat().st_mtime)
-            # Remove oldest files to stay under limit
-            files_to_remove = files[:len(files) - CACHE_RESPONSES_MAX_FILES]
-            for f in files_to_remove:
-                f.unlink()
-                logger.debug(f"Evicted old cache file: {f.name}")
+        def _do_cleanup():
+            files = list(CACHE_RESPONSES_DIR.glob("*.json"))
+            if len(files) > CACHE_RESPONSES_MAX_FILES:
+                files.sort(key=lambda f: f.stat().st_mtime)
+                for f in files[:len(files) - CACHE_RESPONSES_MAX_FILES]:
+                    f.unlink()
+                    logger.debug(f"Evicted old cache file: {f.name}")
+
+        await asyncio.to_thread(_do_cleanup)
     except Exception as e:
-        logger.error(f"Failed to cleanup cache files: {e}")
+        logger.exception(f"Failed to cleanup cache files: {e}")

188-195: Admin key protection looks correct, but empty ADMIN_API_KEY permanently locks these endpoints.

When ADMIN_API_KEY env var is unset, it defaults to "" (Line 58), and not ADMIN_API_KEY is True, so Line 191 always raises 403. This is secure-by-default, which is good, but it means the cache management endpoints are unusable unless the env var is explicitly configured. Consider logging a warning at startup if ADMIN_API_KEY is not set, so operators know these endpoints are disabled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant