Spaces:
Sleeping
Sleeping
ShunTay12 commited on
Commit ·
cfcf570
0
Parent(s):
Delete model files to prevent Binary files
Browse files- .gitignore +11 -0
- .python-version +1 -0
- Dockerfile +21 -0
- README.md +75 -0
- app/chatbot.py +138 -0
- app/core/chatbot/chains.py +23 -0
- app/core/chatbot/config.py +19 -0
- app/core/chatbot/database.py +41 -0
- app/core/chatbot/llm.py +42 -0
- app/core/chatbot/prompt_templates.py +109 -0
- app/core/detector/config.py +20 -0
- app/core/detector/model.py +47 -0
- app/detector.py +61 -0
- app/schemas/chat.py +28 -0
- app/services/chatbot/history.py +58 -0
- app/services/chatbot/search.py +147 -0
- app/services/detector/prediction.py +59 -0
- app/services/detector/transforms.py +31 -0
- gitattributes +35 -0
- main.py +24 -0
- pyproject.toml +29 -0
- supabase/migrations/20251230120000_create_chat_history.sql +11 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.env
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7-labs
|
| 2 |
+
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
|
| 3 |
+
|
| 4 |
+
# App directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install dependencies with uv (uses cache for speed)
|
| 8 |
+
COPY pyproject.toml ./
|
| 9 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 10 |
+
uv sync --no-dev --python /usr/local/bin/python3
|
| 11 |
+
|
| 12 |
+
# Copy the rest of the project
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Ensure uv uses the project virtualenv
|
| 16 |
+
ENV UV_PROJECT_ENVIRONMENT=/app/.venv
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
# Start the FastAPI app
|
| 21 |
+
CMD ["uv", "run", "main.py"]
|
README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model FastAPI
|
| 2 |
+
|
| 3 |
+
FastAPI service that provides two capabilities:
|
| 4 |
+
|
| 5 |
+
- Deepfake image detection using a SigLIP image-classification backbone with a LoRA adapter.
|
| 6 |
+
- A news-aware chatbot that selectively performs web search via Tavily and responds with verified sources when evidence is found.
|
| 7 |
+
|
| 8 |
+
## Project Layout
|
| 9 |
+
|
| 10 |
+
- main app entry: main.py
|
| 11 |
+
- Deepfake detector: app/detector.py, app/core/detector/_, app/services/detector/_, model/output/siglip-lora-optimized/
|
| 12 |
+
- Chatbot: app/chatbot.py, app/core/chatbot/_, app/services/chatbot/_, app/schemas/chat.py
|
| 13 |
+
- Database migration (chat history table): supabase/migrations/
|
| 14 |
+
|
| 15 |
+
## Requirements
|
| 16 |
+
|
| 17 |
+
- Python 3.13+
|
| 18 |
+
- uv package manager (https://docs.astral.sh/uv/)
|
| 19 |
+
- Access to the SigLIP base model and the LoRA adapter stored at model/output/siglip-lora-optimized/
|
| 20 |
+
|
| 21 |
+
## Environment Variables
|
| 22 |
+
|
| 23 |
+
- DATABASE_URL (or SUPABASE_DATABASE_URL): PostgreSQL connection string for chat history (if save_to_db=true).
|
| 24 |
+
- GROQ_API_KEY: required by the Groq LLM used for responses.
|
| 25 |
+
- OPENROUTER_API_KEY: required by the OpenRouter model used for classification/query rewriting.
|
| 26 |
+
- TAVILY_API_KEY: required for Tavily search.
|
| 27 |
+
- Optional: set CUDA-visible devices as needed for GPU inference.
|
| 28 |
+
|
| 29 |
+
## Setup (local)
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
uv sync
|
| 33 |
+
uv run main.py # starts FastAPI on 0.0.0.0:7860
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
The detector will load the SigLIP base model and apply the LoRA adapter from model/output/siglip-lora-optimized/.
|
| 37 |
+
|
| 38 |
+
## API
|
| 39 |
+
|
| 40 |
+
- POST /detect
|
| 41 |
+
- Form-data file field: file (image). Returns predicted_class (index), predicted_label (from id2label), prediction (real/fake thresholded at P(real) >= 0.90), confidence, and class probabilities.
|
| 42 |
+
- POST /chat
|
| 43 |
+
- JSON: {"query": "...", "session_id": "optional", "save_to_db": true|false}
|
| 44 |
+
- Auto-classifies need for search, optionally queries Tavily, then responds. Returns response.content, session_id, used_search, and search_reason.
|
| 45 |
+
- DELETE /chat/{session_id}
|
| 46 |
+
- Clears chat history (both in-memory guest sessions and DB rows).
|
| 47 |
+
|
| 48 |
+
### Quick cURL examples
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
# Deepfake detection
|
| 52 |
+
curl -X POST "http://localhost:7860/detect" \
|
| 53 |
+
-F "file=@path/to/image.jpg"
|
| 54 |
+
|
| 55 |
+
# Chat (without forcing search decision)
|
| 56 |
+
curl -X POST "http://localhost:7860/chat" \
|
| 57 |
+
-H "Content-Type: application/json" \
|
| 58 |
+
-d '{"query": "Is the latest SpaceX launch successful?", "save_to_db": false}'
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## Docker
|
| 62 |
+
|
| 63 |
+
A Dockerfile is provided using the uv base image.
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
docker build -t model-fast-api .
|
| 67 |
+
docker run -p 7860:7860 --env-file .env model-fast-api
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
The image installs dependencies with uv sync and runs `uv run main.py`.
|
| 71 |
+
|
| 72 |
+
## Notes
|
| 73 |
+
|
| 74 |
+
- The news verification prompt only returns source links when the claim is supported by the retrieved results; otherwise it replies with UNDETERMINED and no links.
|
| 75 |
+
- If chat history persistence is disabled (save_to_db=false), sessions are stored in-memory.
|
app/chatbot.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chatbot API routes.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
+
from psycopg import Connection
|
| 10 |
+
from langchain_core.runnables.history import RunnableWithMessageHistory
|
| 11 |
+
from langchain_tavily import TavilySearch
|
| 12 |
+
|
| 13 |
+
from app.core.chatbot.config import CHAT_HISTORY_TABLE
|
| 14 |
+
from app.core.chatbot.chains import general_chain, news_chain
|
| 15 |
+
from app.core.chatbot.database import db_connection_dependency
|
| 16 |
+
from app.schemas.chat import ChatRequest
|
| 17 |
+
from app.services.chatbot.history import delete_guest_session, get_message_history
|
| 18 |
+
from app.services.chatbot.search import (
|
| 19 |
+
extract_search_query,
|
| 20 |
+
format_search_results,
|
| 21 |
+
get_tavily_search,
|
| 22 |
+
should_use_search,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
chat = APIRouter()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@chat.post("/chat")
|
| 31 |
+
async def chat_endpoint(
|
| 32 |
+
request: ChatRequest,
|
| 33 |
+
tavily_search: TavilySearch = Depends(get_tavily_search),
|
| 34 |
+
):
|
| 35 |
+
"""
|
| 36 |
+
Chat endpoint with intelligent query processing.
|
| 37 |
+
|
| 38 |
+
Flow:
|
| 39 |
+
1. LLM classifies if query needs web search
|
| 40 |
+
2. If NO search needed -> respond directly with LLM knowledge
|
| 41 |
+
3. If search needed -> extract optimized search query -> search with Tavily -> respond with sources
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
response: The LLM's response
|
| 45 |
+
session_id: Session ID for conversation history
|
| 46 |
+
used_search: Whether web search was used
|
| 47 |
+
search_reason: Why search was/wasn't used
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
# Ensure every session has an id for history tracking (DB or in-memory)
|
| 51 |
+
session_id = request.session_id or str(uuid4())
|
| 52 |
+
save_to_db = request.save_to_db
|
| 53 |
+
|
| 54 |
+
# Step 1: LLM decides if search is needed
|
| 55 |
+
needs_search, search_reason = should_use_search(request.query)
|
| 56 |
+
logger.info("Search needed: %s, Reason: %s", needs_search, search_reason)
|
| 57 |
+
|
| 58 |
+
def get_history(sid: str):
|
| 59 |
+
return get_message_history(sid, save_to_db)
|
| 60 |
+
|
| 61 |
+
# Step 2: Take appropriate path
|
| 62 |
+
if not needs_search:
|
| 63 |
+
# Respond directly without search
|
| 64 |
+
chain_with_history = RunnableWithMessageHistory(
|
| 65 |
+
general_chain,
|
| 66 |
+
get_history,
|
| 67 |
+
input_messages_key="question",
|
| 68 |
+
history_messages_key="chat_history",
|
| 69 |
+
)
|
| 70 |
+
response = chain_with_history.invoke(
|
| 71 |
+
{"question": request.query},
|
| 72 |
+
config={"configurable": {"session_id": session_id}},
|
| 73 |
+
)
|
| 74 |
+
else:
|
| 75 |
+
# Step 2a: Extract optimized search query
|
| 76 |
+
optimized_query = extract_search_query(request.query)
|
| 77 |
+
logger.info("Optimized search query: %s", optimized_query)
|
| 78 |
+
# Step 2b: Search with optimized query
|
| 79 |
+
search_results = tavily_search.invoke(optimized_query)
|
| 80 |
+
formatted_results = format_search_results(search_results)
|
| 81 |
+
logger.debug("Search results: %s", search_results)
|
| 82 |
+
|
| 83 |
+
# Step 2c: Respond with sources
|
| 84 |
+
chain_with_history = RunnableWithMessageHistory(
|
| 85 |
+
news_chain,
|
| 86 |
+
get_history,
|
| 87 |
+
input_messages_key="question",
|
| 88 |
+
history_messages_key="chat_history",
|
| 89 |
+
)
|
| 90 |
+
response = chain_with_history.invoke(
|
| 91 |
+
{
|
| 92 |
+
"question": request.query,
|
| 93 |
+
"search_results": formatted_results,
|
| 94 |
+
},
|
| 95 |
+
config={"configurable": {"session_id": session_id}},
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
logger.debug("Response: %s", response.content)
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"response": {
|
| 102 |
+
"content": response.content,
|
| 103 |
+
},
|
| 104 |
+
"session_id": session_id,
|
| 105 |
+
"used_search": needs_search,
|
| 106 |
+
"search_reason": search_reason,
|
| 107 |
+
}
|
| 108 |
+
except HTTPException:
|
| 109 |
+
raise
|
| 110 |
+
except Exception as exc: # pragma: no cover - defensive server guard
|
| 111 |
+
logger.exception("Unhandled error in chat_endpoint")
|
| 112 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@chat.delete("/chat/{session_id}")
|
| 116 |
+
async def delete_chat_history(session_id: str, conn: Connection = Depends(db_connection_dependency)):
|
| 117 |
+
"""Delete chat history for a specific session."""
|
| 118 |
+
try:
|
| 119 |
+
# Delete from in-memory guest sessions
|
| 120 |
+
delete_guest_session(session_id)
|
| 121 |
+
|
| 122 |
+
# Delete from database
|
| 123 |
+
with conn.cursor() as cursor:
|
| 124 |
+
cursor.execute(
|
| 125 |
+
f'DELETE FROM "{CHAT_HISTORY_TABLE}" WHERE session_id = %s',
|
| 126 |
+
(session_id,),
|
| 127 |
+
)
|
| 128 |
+
deleted_count = cursor.rowcount
|
| 129 |
+
conn.commit()
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"success": True,
|
| 133 |
+
"deleted_count": deleted_count,
|
| 134 |
+
"session_id": session_id,
|
| 135 |
+
}
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.exception("Error in delete_chat_history")
|
| 138 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/core/chatbot/chains.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LangChain chain definitions.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from app.core.chatbot.llm import classify_query_llm, construct_query_llm, response_llm
|
| 6 |
+
from app.core.chatbot.prompt_templates import (
|
| 7 |
+
classification_prompt,
|
| 8 |
+
search_query_prompt,
|
| 9 |
+
general_prompt,
|
| 10 |
+
news_verification_prompt,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
# Chain for classifying if a query needs web search
|
| 14 |
+
classification_chain = classification_prompt | construct_query_llm
|
| 15 |
+
|
| 16 |
+
# Chain for extracting optimized search queries
|
| 17 |
+
search_query_chain = search_query_prompt | construct_query_llm
|
| 18 |
+
|
| 19 |
+
# Chain for general responses (no search)
|
| 20 |
+
general_chain = general_prompt | response_llm
|
| 21 |
+
|
| 22 |
+
# Chain for news verification responses (with search results)
|
| 23 |
+
news_chain = news_verification_prompt | response_llm
|
app/core/chatbot/config.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for environment variables and database settings.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
# Database connection string
|
| 11 |
+
DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv("SUPABASE_DATABASE_URL")
|
| 12 |
+
|
| 13 |
+
if not DATABASE_URL:
|
| 14 |
+
raise ValueError(
|
| 15 |
+
"DATABASE_URL or SUPABASE_DATABASE_URL environment variable is required"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Table name for chat history
|
| 19 |
+
CHAT_HISTORY_TABLE = "chat_history"
|
app/core/chatbot/database.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database utilities for chat history.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Iterator
|
| 6 |
+
|
| 7 |
+
from psycopg_pool import ConnectionPool
|
| 8 |
+
|
| 9 |
+
from app.core.chatbot.config import DATABASE_URL
|
| 10 |
+
|
| 11 |
+
# Connection pool for efficient connection management
|
| 12 |
+
# min_size=1 keeps at least 1 connection ready
|
| 13 |
+
# max_size=10 limits concurrent connections
|
| 14 |
+
_connection_pool = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_connection_pool() -> ConnectionPool:
|
| 18 |
+
"""Get or create the connection pool (lazy initialization)."""
|
| 19 |
+
|
| 20 |
+
global _connection_pool
|
| 21 |
+
if _connection_pool is None:
|
| 22 |
+
_connection_pool = ConnectionPool(
|
| 23 |
+
DATABASE_URL,
|
| 24 |
+
min_size=1,
|
| 25 |
+
max_size=10,
|
| 26 |
+
kwargs={"connect_timeout": 10},
|
| 27 |
+
)
|
| 28 |
+
return _connection_pool
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_db_connection():
|
| 32 |
+
"""Get a database connection from the pool."""
|
| 33 |
+
|
| 34 |
+
return get_connection_pool().connection()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def db_connection_dependency() -> Iterator:
|
| 38 |
+
"""FastAPI dependency that yields a pooled DB connection."""
|
| 39 |
+
|
| 40 |
+
with get_db_connection() as conn:
|
| 41 |
+
yield conn
|
app/core/chatbot/llm.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM client initialization module.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# from langchain_google_genai import ChatGoogleGenerativeAI
|
| 8 |
+
from langchain_groq import ChatGroq
|
| 9 |
+
from langchain_openai import ChatOpenAI
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# LLM for constructing optimized search querie
|
| 13 |
+
construct_query_llm = ChatOpenAI(
|
| 14 |
+
model="xiaomi/mimo-v2-flash:free",
|
| 15 |
+
api_key=os.getenv("OPENROUTER_API_KEY"),
|
| 16 |
+
base_url="https://openrouter.ai/api/v1",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Main LLM for generating responses
|
| 20 |
+
response_llm = ChatGroq(
|
| 21 |
+
model="meta-llama/llama-4-scout-17b-16e-instruct",
|
| 22 |
+
temperature=0,
|
| 23 |
+
max_tokens=None,
|
| 24 |
+
timeout=None,
|
| 25 |
+
max_retries=2,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Set classify_query_llm to reuse construct_query_llm
|
| 29 |
+
classify_query_llm = construct_query_llm
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LLMService:
|
| 33 |
+
"""
|
| 34 |
+
LLM Service class to encapsulate LLM clients.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.models = {
|
| 39 |
+
"construct_query_llm": construct_query_llm,
|
| 40 |
+
"response_llm": response_llm,
|
| 41 |
+
"classify_query_llm": classify_query_llm,
|
| 42 |
+
}
|
app/core/chatbot/prompt_templates.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt templates for the chatbot.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 6 |
+
|
| 7 |
+
# Prompt to classify if query needs web search
|
| 8 |
+
classification_prompt = ChatPromptTemplate.from_messages(
|
| 9 |
+
[
|
| 10 |
+
(
|
| 11 |
+
"system",
|
| 12 |
+
"""You are a query classifier. Analyze the user's input and determine if it requires searching the web for verification or current information.
|
| 13 |
+
|
| 14 |
+
Return ONLY a JSON object in this exact format:
|
| 15 |
+
{{"needs_search": true/false, "reason": "brief explanation"}}
|
| 16 |
+
|
| 17 |
+
Set needs_search to TRUE if:
|
| 18 |
+
- Query asks about recent news, current events, or breaking stories
|
| 19 |
+
- Query asks to verify/fact-check a claim or statement
|
| 20 |
+
- Query mentions specific dates, "today", "yesterday", "this week", etc.
|
| 21 |
+
- Query asks about something that may have changed recently
|
| 22 |
+
- User makes a statement of fact about current events that you cannot verify from your training data
|
| 23 |
+
- User claims something happened recently (appointments, elections, announcements, etc.)
|
| 24 |
+
- The statement contains specific names, positions, or events you're unsure about
|
| 25 |
+
|
| 26 |
+
Set needs_search to FALSE if:
|
| 27 |
+
- Query is a greeting (e.g., "Hello", "Hi", "Good morning")
|
| 28 |
+
- Query is a personal introduction or statement about themselves (e.g., "My name is...", "I am a developer")
|
| 29 |
+
- Query is about general knowledge, concepts, or definitions that don't change
|
| 30 |
+
- Query asks about well-established historical facts (before 2024)
|
| 31 |
+
- Query is about how things work, explanations, or tutorials
|
| 32 |
+
- Query is personal advice, opinions, or hypothetical scenarios
|
| 33 |
+
|
| 34 |
+
Examples:
|
| 35 |
+
- "My name is John" -> {{"needs_search": false, "reason": "User personal introduction"}}
|
| 36 |
+
- "Hello, who are you?" -> {{"needs_search": false, "reason": "Greeting/Chitchat"}}
|
| 37 |
+
- "Is it true that SpaceX launched yesterday?" -> {{"needs_search": true, "reason": "Recent event needs verification"}}
|
| 38 |
+
- "What is photosynthesis?" -> {{"needs_search": false, "reason": "General scientific knowledge"}}
|
| 39 |
+
- "Abu Bakar Hamzah was appointed as menteri besar of Perlis" -> {{"needs_search": true, "reason": "Statement about political appointment needs verification"}}
|
| 40 |
+
- "Trump won the 2024 election" -> {{"needs_search": true, "reason": "Recent political event needs verification"}}
|
| 41 |
+
- "The earth orbits the sun" -> {{"needs_search": false, "reason": "Established scientific fact"}}""",
|
| 42 |
+
),
|
| 43 |
+
("human", "{question}"),
|
| 44 |
+
]
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Prompt to extract search query from user's question
|
| 48 |
+
search_query_prompt = ChatPromptTemplate.from_messages(
|
| 49 |
+
[
|
| 50 |
+
(
|
| 51 |
+
"system",
|
| 52 |
+
"""You are a search query extractor. Given a user's question about news or current events, extract the key facts and create an optimal search query.
|
| 53 |
+
|
| 54 |
+
Your task:
|
| 55 |
+
1. Identify the key entities (people, places, organizations, events)
|
| 56 |
+
2. Remove unnecessary words like "Is it true that", "Did", "Has", etc.
|
| 57 |
+
3. Create a concise, focused search query that will find relevant news articles
|
| 58 |
+
|
| 59 |
+
Return ONLY a JSON object in this exact format:
|
| 60 |
+
{{"search_query": "the optimized search query"}}
|
| 61 |
+
|
| 62 |
+
Examples:
|
| 63 |
+
- "Is the news true that Biden signed a new climate bill?" -> {{"search_query": "Biden climate bill signed"}}
|
| 64 |
+
- "Did SpaceX successfully launch Starship yesterday?" -> {{"search_query": "SpaceX Starship launch"}}
|
| 65 |
+
- "Is it true that Apple announced a new iPhone model?" -> {{"search_query": "Apple new iPhone announcement"}}
|
| 66 |
+
- "Is the news true, Bersatu's Kuala Perlis assemblyman Abu Bakar Hamzah appointed as 12th menteri besar of Perlis?" -> {{"search_query": "Abu Bakar Hamzah menteri besar Perlis"}}""",
|
| 67 |
+
),
|
| 68 |
+
("human", "{question}"),
|
| 69 |
+
]
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# General response prompt (when no search needed)
|
| 73 |
+
general_prompt = ChatPromptTemplate.from_messages(
|
| 74 |
+
[
|
| 75 |
+
(
|
| 76 |
+
"system",
|
| 77 |
+
"""You are a helpful assistant. Answer the user's question based on your knowledge.
|
| 78 |
+
Be informative and accurate. If you're not sure about something, say so.""",
|
| 79 |
+
),
|
| 80 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 81 |
+
("human", "{question}"),
|
| 82 |
+
]
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# News verification prompt (when search results are used)
|
| 86 |
+
news_verification_prompt = ChatPromptTemplate.from_messages(
|
| 87 |
+
[
|
| 88 |
+
(
|
| 89 |
+
"system",
|
| 90 |
+
"""You are a helpful news verification assistant. Analyze the search results to answer the user's query.
|
| 91 |
+
|
| 92 |
+
When responding:
|
| 93 |
+
- Use the search results to provide accurate, up-to-date information.
|
| 94 |
+
- Only include source links when the claim is matched and clearly supported by the search results.
|
| 95 |
+
- If the claim cannot be confirmed with recent or credible information, respond with exactly: UNDETERMINED, and do not include any sources.
|
| 96 |
+
- Be clear and cite your sources with URLs only when the information is confirmed.""",
|
| 97 |
+
),
|
| 98 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 99 |
+
(
|
| 100 |
+
"human",
|
| 101 |
+
"""User's query: {question}
|
| 102 |
+
|
| 103 |
+
Search results:
|
| 104 |
+
{search_results}
|
| 105 |
+
|
| 106 |
+
Respond based on these search results.""",
|
| 107 |
+
),
|
| 108 |
+
]
|
| 109 |
+
)
|
app/core/detector/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Detector configuration - device settings, model paths, and thresholds.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import warnings
|
| 7 |
+
|
| 8 |
+
warnings.filterwarnings("ignore")
|
| 9 |
+
|
| 10 |
+
# Check for GPU availability
|
| 11 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 12 |
+
print(f"Using device: {DEVICE}")
|
| 13 |
+
if torch.cuda.is_available():
|
| 14 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 15 |
+
|
| 16 |
+
# Model configuration
|
| 17 |
+
BASE_MODEL_NAME = "shunda012/siglip-deepfake-detector"
|
| 18 |
+
|
| 19 |
+
# Prediction threshold
|
| 20 |
+
REAL_THRESHOLD = 0.90 # classify as real only when P(real) >= 90%
|
app/core/detector/model.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model loading for the deepfake detector.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from transformers import AutoImageProcessor, SiglipForImageClassification
|
| 9 |
+
|
| 10 |
+
from app.core.detector.config import BASE_MODEL_NAME, DEVICE
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class SiglipResources:
|
| 15 |
+
"""Container for the SigLIP model and processor."""
|
| 16 |
+
|
| 17 |
+
model: SiglipForImageClassification
|
| 18 |
+
processor: AutoImageProcessor
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
_siglip_resources: Optional[SiglipResources] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_siglip_model() -> SiglipResources:
|
| 25 |
+
"""
|
| 26 |
+
Get or load the merged SigLIP detector model.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
SiglipResources: Loaded model and processor (cached singleton).
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
global _siglip_resources
|
| 33 |
+
|
| 34 |
+
if _siglip_resources is None:
|
| 35 |
+
print("Loading SigLIP Model...")
|
| 36 |
+
|
| 37 |
+
siglip_processor = AutoImageProcessor.from_pretrained(BASE_MODEL_NAME)
|
| 38 |
+
siglip_model = SiglipForImageClassification.from_pretrained(BASE_MODEL_NAME)
|
| 39 |
+
siglip_model = siglip_model.to(DEVICE)
|
| 40 |
+
siglip_model.eval()
|
| 41 |
+
|
| 42 |
+
_siglip_resources = SiglipResources(
|
| 43 |
+
model=siglip_model,
|
| 44 |
+
processor=siglip_processor,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
return _siglip_resources
|
app/detector.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Detector API routes.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
|
| 9 |
+
from typing import Callable
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 12 |
+
from PIL import Image, UnidentifiedImageError
|
| 13 |
+
|
| 14 |
+
from app.core.detector.model import SiglipResources, get_siglip_model
|
| 15 |
+
from app.services.detector.prediction import predict_single_image
|
| 16 |
+
from app.services.detector.transforms import get_eval_transforms
|
| 17 |
+
|
| 18 |
+
detector = APIRouter()
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@lru_cache(maxsize=1)
|
| 23 |
+
def get_siglip_transforms():
|
| 24 |
+
"""Build and cache SigLIP evaluation transforms once per process."""
|
| 25 |
+
|
| 26 |
+
resources = get_siglip_model()
|
| 27 |
+
return get_eval_transforms(resources.processor, "siglip")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@detector.post("/detect")
|
| 31 |
+
async def detect_deepfake(
|
| 32 |
+
file: UploadFile = File(...),
|
| 33 |
+
resources: SiglipResources = Depends(get_siglip_model),
|
| 34 |
+
siglip_transforms: Callable = Depends(get_siglip_transforms),
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Detect if an image is a deepfake or real using SigLIP + LoRA model.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
file: Uploaded image file
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
JSON response with prediction results
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
image_bytes = await file.read()
|
| 48 |
+
image = Image.open(BytesIO(image_bytes)).convert("RGB")
|
| 49 |
+
|
| 50 |
+
result = predict_single_image(
|
| 51 |
+
image, resources.model, siglip_transforms, "SigLIP + LoRA"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
return result
|
| 55 |
+
except UnidentifiedImageError:
|
| 56 |
+
raise HTTPException(status_code=422, detail="Invalid or unsupported image file")
|
| 57 |
+
except HTTPException:
|
| 58 |
+
raise
|
| 59 |
+
except Exception as exc: # pragma: no cover - defensive server guard
|
| 60 |
+
logger.exception("Unhandled error during deepfake detection")
|
| 61 |
+
raise HTTPException(status_code=500, detail="Error processing image") from exc
|
app/schemas/chat.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic models for chat API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ChatRequest(BaseModel):
|
| 10 |
+
"""Request model for the chat endpoint."""
|
| 11 |
+
query: str
|
| 12 |
+
session_id: Optional[str] = None
|
| 13 |
+
save_to_db: bool = True
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ChatResponse(BaseModel):
|
| 17 |
+
"""Response model for the chat endpoint."""
|
| 18 |
+
response: dict
|
| 19 |
+
session_id: Optional[str]
|
| 20 |
+
used_search: bool
|
| 21 |
+
search_reason: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class DeleteResponse(BaseModel):
|
| 25 |
+
"""Response model for the delete endpoint."""
|
| 26 |
+
success: bool
|
| 27 |
+
deleted_count: int
|
| 28 |
+
session_id: str
|
app/services/chatbot/history.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session and message history management.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from langchain_postgres import PostgresChatMessageHistory
|
| 6 |
+
from langchain_core.chat_history import InMemoryChatMessageHistory
|
| 7 |
+
from app.core.chatbot.config import DATABASE_URL, CHAT_HISTORY_TABLE
|
| 8 |
+
from app.core.chatbot.database import get_connection_pool
|
| 9 |
+
|
| 10 |
+
# In-memory store for guest sessions
|
| 11 |
+
guest_sessions: dict[str, InMemoryChatMessageHistory] = {}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_message_history(session_id: str, save_to_db: bool = True):
|
| 15 |
+
"""
|
| 16 |
+
Get message history for a session.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
session_id: The session identifier
|
| 20 |
+
save_to_db: If True, use PostgreSQL storage; if False, use in-memory storage
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Message history instance (PostgresChatMessageHistory or InMemoryChatMessageHistory)
|
| 24 |
+
|
| 25 |
+
Note:
|
| 26 |
+
For PostgreSQL storage, the connection is managed by the connection pool.
|
| 27 |
+
The PostgresChatMessageHistory will use a pooled connection that is
|
| 28 |
+
properly returned to the pool when done.
|
| 29 |
+
"""
|
| 30 |
+
if save_to_db:
|
| 31 |
+
# Get a connection from the pool
|
| 32 |
+
# The pool manages the connection lifecycle
|
| 33 |
+
pool = get_connection_pool()
|
| 34 |
+
return PostgresChatMessageHistory(
|
| 35 |
+
CHAT_HISTORY_TABLE,
|
| 36 |
+
session_id,
|
| 37 |
+
sync_connection=pool, # Pool handles connection management
|
| 38 |
+
)
|
| 39 |
+
else:
|
| 40 |
+
if session_id not in guest_sessions:
|
| 41 |
+
guest_sessions[session_id] = InMemoryChatMessageHistory()
|
| 42 |
+
return guest_sessions[session_id]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def delete_guest_session(session_id: str) -> bool:
|
| 46 |
+
"""
|
| 47 |
+
Delete a guest session from in-memory storage.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
session_id: The session identifier
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
True if session was deleted, False if it didn't exist
|
| 54 |
+
"""
|
| 55 |
+
if session_id in guest_sessions:
|
| 56 |
+
del guest_sessions[session_id]
|
| 57 |
+
return True
|
| 58 |
+
return False
|
app/services/chatbot/search.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Search utilities for Tavily integration and query processing.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
|
| 8 |
+
from langchain_tavily import TavilySearch
|
| 9 |
+
|
| 10 |
+
from app.core.chatbot.chains import classification_chain, search_query_chain
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@lru_cache(maxsize=1)
|
| 14 |
+
def get_tavily_search() -> TavilySearch:
|
| 15 |
+
"""Get or create Tavily Search instance (process-wide cache)."""
|
| 16 |
+
|
| 17 |
+
return TavilySearch(
|
| 18 |
+
max_results=3,
|
| 19 |
+
topic="general",
|
| 20 |
+
include_answer=None,
|
| 21 |
+
search_depth="basic",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def should_use_search(query: str) -> tuple[bool, str]:
|
| 26 |
+
"""
|
| 27 |
+
Use LLM to decide if query needs web search.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
query: The user's query
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Tuple of (needs_search: bool, reason: str)
|
| 34 |
+
"""
|
| 35 |
+
# Quick pre-check for simple greetings (no LLM needed)
|
| 36 |
+
simple_patterns = [
|
| 37 |
+
"hi",
|
| 38 |
+
"hello",
|
| 39 |
+
"hey",
|
| 40 |
+
"good morning",
|
| 41 |
+
"good afternoon",
|
| 42 |
+
"good evening",
|
| 43 |
+
"good night",
|
| 44 |
+
"thanks",
|
| 45 |
+
"thank you",
|
| 46 |
+
"bye",
|
| 47 |
+
"goodbye",
|
| 48 |
+
"ok",
|
| 49 |
+
"okay",
|
| 50 |
+
"yes",
|
| 51 |
+
"no",
|
| 52 |
+
"sure",
|
| 53 |
+
"help",
|
| 54 |
+
"what can you do",
|
| 55 |
+
"who are you",
|
| 56 |
+
]
|
| 57 |
+
query_lower = query.lower().strip()
|
| 58 |
+
|
| 59 |
+
# Check if query is a simple greeting or short phrase
|
| 60 |
+
if query_lower in simple_patterns or len(query_lower) < 5:
|
| 61 |
+
return False, "Simple greeting or short phrase"
|
| 62 |
+
|
| 63 |
+
# Check if starts with common greeting words
|
| 64 |
+
greeting_starters = ["hi ", "hello ", "hey ", "thanks ", "thank you"]
|
| 65 |
+
for starter in greeting_starters:
|
| 66 |
+
if query_lower.startswith(starter) and len(query_lower) < 30:
|
| 67 |
+
return False, "Greeting message"
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
response = classification_chain.invoke({"question": query})
|
| 71 |
+
content = response.content.strip()
|
| 72 |
+
|
| 73 |
+
# Extract JSON from response (handle markdown code blocks)
|
| 74 |
+
if "```" in content:
|
| 75 |
+
content = content.split("```")[1]
|
| 76 |
+
if content.startswith("json"):
|
| 77 |
+
content = content[4:]
|
| 78 |
+
|
| 79 |
+
result = json.loads(content)
|
| 80 |
+
needs_search = result.get("needs_search", False)
|
| 81 |
+
reason = result.get("reason", "")
|
| 82 |
+
return needs_search, reason
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Classification error: {e}")
|
| 85 |
+
# Default to NOT searching if classification fails (safer for simple queries)
|
| 86 |
+
return False, "Classification failed, defaulting to no search"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def extract_search_query(query: str) -> str:
|
| 90 |
+
"""
|
| 91 |
+
Use LLM to extract an optimized search query from user's question.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
query: The user's original query
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Optimized search query string
|
| 98 |
+
"""
|
| 99 |
+
try:
|
| 100 |
+
response = search_query_chain.invoke({"question": query})
|
| 101 |
+
content = response.content.strip()
|
| 102 |
+
|
| 103 |
+
# Extract JSON from response (handle markdown code blocks)
|
| 104 |
+
if "```" in content:
|
| 105 |
+
content = content.split("```")[1]
|
| 106 |
+
if content.startswith("json"):
|
| 107 |
+
content = content[4:]
|
| 108 |
+
|
| 109 |
+
result = json.loads(content)
|
| 110 |
+
search_query = result.get("search_query", query)
|
| 111 |
+
print(f"Extracted search query: {search_query}")
|
| 112 |
+
return search_query
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f"Search query extraction error: {e}")
|
| 115 |
+
# Fall back to original query
|
| 116 |
+
return query
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def format_search_results(search_results) -> str:
|
| 120 |
+
"""
|
| 121 |
+
Format Tavily search results for the LLM prompt.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
search_results: Raw search results from Tavily
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Formatted string of search results
|
| 128 |
+
"""
|
| 129 |
+
formatted = ""
|
| 130 |
+
|
| 131 |
+
if isinstance(search_results, dict):
|
| 132 |
+
if "answer" in search_results and search_results["answer"]:
|
| 133 |
+
formatted += f"Summary: {search_results['answer']}\n\n"
|
| 134 |
+
|
| 135 |
+
if "results" in search_results:
|
| 136 |
+
formatted += "Sources:\n"
|
| 137 |
+
for i, result in enumerate(search_results["results"], 1):
|
| 138 |
+
formatted += f"\n{i}. {result.get('title', 'No title')}\n"
|
| 139 |
+
formatted += f" URL: {result.get('url', 'No URL')}\n"
|
| 140 |
+
formatted += f" Content: {result.get('content', 'No content')}\n"
|
| 141 |
+
elif isinstance(search_results, str):
|
| 142 |
+
formatted = search_results
|
| 143 |
+
|
| 144 |
+
if not formatted.strip():
|
| 145 |
+
formatted = "No relevant search results found."
|
| 146 |
+
|
| 147 |
+
return formatted
|
app/services/detector/prediction.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prediction logic for the deepfake detector.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from app.core.detector.config import DEVICE, REAL_THRESHOLD
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def predict_single_image(image, model, transforms, model_name="Model"):
|
| 10 |
+
"""
|
| 11 |
+
Predict whether a single image is real or fake using a strict real-probability threshold.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
image: PIL Image object
|
| 15 |
+
model: The PyTorch model to use
|
| 16 |
+
transforms: Image transforms to apply
|
| 17 |
+
model_name: Name of the model for display
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
Dictionary with prediction result and confidence
|
| 21 |
+
"""
|
| 22 |
+
# Load and preprocess image
|
| 23 |
+
pixel_values = transforms(image).unsqueeze(0).to(DEVICE)
|
| 24 |
+
|
| 25 |
+
# Run inference
|
| 26 |
+
model.eval()
|
| 27 |
+
with torch.no_grad():
|
| 28 |
+
outputs = model(pixel_values)
|
| 29 |
+
logits = outputs.logits
|
| 30 |
+
predicted_class = torch.argmax(logits, dim=1).item()
|
| 31 |
+
label = model.config.id2label[predicted_class]
|
| 32 |
+
probs = torch.softmax(logits, dim=-1)
|
| 33 |
+
fake_prob = probs[0][0].item()
|
| 34 |
+
real_prob = probs[0][1].item()
|
| 35 |
+
|
| 36 |
+
# Apply threshold rule: only label real if real_prob >= 90%
|
| 37 |
+
formatted_prediction = "real" if real_prob >= REAL_THRESHOLD else "fake"
|
| 38 |
+
confidence = real_prob if formatted_prediction == "real" else fake_prob
|
| 39 |
+
|
| 40 |
+
# Display results
|
| 41 |
+
print(f"\n{'='*50}")
|
| 42 |
+
print(f"Prediction Results ({model_name})")
|
| 43 |
+
print(f"{'='*50}")
|
| 44 |
+
print(f"Threshold rule: real if P(real) >= {REAL_THRESHOLD:.0%}")
|
| 45 |
+
print(f"Prediction: {formatted_prediction.upper()}")
|
| 46 |
+
print(f"Confidence: {confidence:.2%}")
|
| 47 |
+
print(f"Predicted class: {predicted_class} ({label})")
|
| 48 |
+
print(f"{'='*50}")
|
| 49 |
+
print(f"\nClass Probabilities:")
|
| 50 |
+
print(f" Fake: {fake_prob:.2%}")
|
| 51 |
+
print(f" Real: {real_prob:.2%}")
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"formatted_prediction": formatted_prediction,
|
| 55 |
+
"predicted_class": predicted_class,
|
| 56 |
+
"predicted_label": label,
|
| 57 |
+
"confidence": confidence,
|
| 58 |
+
"probabilities": {"fake": fake_prob, "real": real_prob},
|
| 59 |
+
}
|
app/services/detector/transforms.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image transforms for the deepfake detector.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from torchvision.transforms import Compose, Resize, CenterCrop, ToTensor, Normalize
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_eval_transforms(processor, model_type="vit"):
|
| 9 |
+
"""
|
| 10 |
+
Create evaluation transforms based on processor settings.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
processor: The image processor from the model
|
| 14 |
+
model_type: Type of model ("vit" or "siglip")
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Composed transforms for image preprocessing
|
| 18 |
+
"""
|
| 19 |
+
size = processor.size["height"]
|
| 20 |
+
image_mean = processor.image_mean
|
| 21 |
+
image_std = processor.image_std
|
| 22 |
+
normalize = Normalize(mean=image_mean, std=image_std)
|
| 23 |
+
|
| 24 |
+
return Compose(
|
| 25 |
+
[
|
| 26 |
+
Resize(size if model_type == "siglip" else 256),
|
| 27 |
+
CenterCrop(size),
|
| 28 |
+
ToTensor(),
|
| 29 |
+
normalize,
|
| 30 |
+
]
|
| 31 |
+
)
|
gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
from app.detector import detector
|
| 6 |
+
from app.chatbot import chat
|
| 7 |
+
|
| 8 |
+
app = FastAPI()
|
| 9 |
+
|
| 10 |
+
# Add CORS middleware
|
| 11 |
+
app.add_middleware(
|
| 12 |
+
CORSMiddleware,
|
| 13 |
+
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
| 14 |
+
allow_credentials=True,
|
| 15 |
+
allow_methods=["*"],
|
| 16 |
+
allow_headers=["*"],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
app.include_router(detector, tags=["deepfake detector api"])
|
| 20 |
+
app.include_router(chat, tags=["chat api"])
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
|
| 24 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "model-fast-api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi>=0.118.0",
|
| 9 |
+
"langchain>=0.3.27",
|
| 10 |
+
"langchain-community>=0.3.31",
|
| 11 |
+
"langchain-deepseek>=1.0.1",
|
| 12 |
+
"langchain-google-genai>=2.1.12",
|
| 13 |
+
"langchain-groq>=1.1.1",
|
| 14 |
+
"langchain-postgres>=0.0.15",
|
| 15 |
+
"langchain-tavily>=0.1.0",
|
| 16 |
+
"peft>=0.18.0",
|
| 17 |
+
"pillow>=11.0.0",
|
| 18 |
+
"protobuf>=6.32.1",
|
| 19 |
+
"psycopg>=3.2.10",
|
| 20 |
+
"psycopg-binary>=3.2.10",
|
| 21 |
+
"psycopg-pool>=3.2.6",
|
| 22 |
+
"python-dotenv>=1.1.1",
|
| 23 |
+
"python-multipart>=0.0.20",
|
| 24 |
+
"safetensors>=0.6.2",
|
| 25 |
+
"sentencepiece>=0.2.1",
|
| 26 |
+
"supabase>=2.22.0",
|
| 27 |
+
"transformers>=4.57.0",
|
| 28 |
+
"uvicorn>=0.37.0",
|
| 29 |
+
]
|
supabase/migrations/20251230120000_create_chat_history.sql
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Chat message history storage for LangChain PostgresChatMessageHistory
|
| 2 |
+
-- Handles pooled connections in app.core.database; schema is managed via Supabase migrations
|
| 3 |
+
|
| 4 |
+
create table if not exists public.chat_history (
|
| 5 |
+
id bigint generated by default as identity primary key,
|
| 6 |
+
session_id uuid not null,
|
| 7 |
+
message jsonb not null,
|
| 8 |
+
created_at timestamptz not null default now()
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
create index if not exists chat_history_session_id_idx on public.chat_history (session_id);
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|