briefly-ai-api / main.py
github-actions
deploy: sync to Hugging Face Spaces via GitHub Actions
1e5ffe0
Raw
History Blame Contribute Delete
10.9 kB
import time
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from pydantic import BaseModel, Field
from transformers import pipeline
from typing import List
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("summarizer-backend")
# Global pipeline reference
text_summarizer = None
text_ner = None
text_tone = None
text_keywords = None
SUMMARIZATION_MODEL_NAME = "sshleifer/distilbart-cnn-6-6"
NER_MODEL_NAME = "elastic/distilbert-base-uncased-finetuned-conll03-english"
TONE_MODEL_NAME = "tasksource/ModernBERT-base-nli"
KEYWORDS_MODEL_NAME = "ml6team/keyphrase-extraction-distilbert-inspec"
candidate_tones = [
"informational / instructional", # 1. Facts, guides, or tutorials
"talk / conversational", # 2. Casual speech, texting, or chatting
"promotional / marketing", # 3. Sales pitches, ads, or persuasion
"academic / scholarly", # 4. Research, formal studies, or dense theory
"narrative / storytelling", # 5. Fiction, anecdotes, or describing events
"opinion / editorial", # 6. Personal viewpoints, commentary, or reviews
"professional / corporate", # 7. Business emails, updates, or reports
"urgent / critical", # 8. Alerts, warnings, or time-sensitive news
"creative / poetic", # 9. Expressive, artistic, or stylistic text
"legal / administrative" # 10. Terms of service, contracts, or official rules
]
@asynccontextmanager
async def lifespan(app: FastAPI):
global text_summarizer, text_ner, text_tone, text_keywords
logger.info("Initializing NLP models...")
start_time = time.time()
try:
text_summarizer = pipeline("summarization", model=SUMMARIZATION_MODEL_NAME, framework="pt")
text_ner = pipeline("ner", model=NER_MODEL_NAME, framework="pt", aggregation_strategy="simple")
text_tone = pipeline("zero-shot-classification", model=TONE_MODEL_NAME, framework="pt")
text_keywords = pipeline("token-classification", model=KEYWORDS_MODEL_NAME, framework="pt", aggregation_strategy="simple")
duration = time.time() - start_time
logger.info(f"All models loaded successfully in {duration:.2f} seconds.")
except Exception as e:
logger.error(f"Failed to load pipelines: {str(e)}")
raise e
yield
logger.info("Cleaning up backend lifespan resources...")
app = FastAPI(
title="Enhanced Text Analysis API",
description="FastAPI text engine featuring Summarization, NER, Tone Analysis, and Keyphrase Extraction.",
version="1.0.0",
lifespan=lifespan,
docs_url=None,
redoc_url=None
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url="openapi.json",
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="https://fastly.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
swagger_css_url="https://fastly.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css",
)
@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url="openapi.json",
title=app.title + " - ReDoc",
redoc_js_url="https://fastly.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js",
)
# --- REQUEST / RESPONSE SCHEMAS ---
class TokenizeRequest(BaseModel):
text: str = Field(..., description="The text to tokenize.")
class TokenInfo(BaseModel):
id: int
text: str
color_index: int
class TokenizeResponse(BaseModel):
tokens: List[TokenInfo]
token_count: int
class SummarizeRequest(BaseModel):
text: str = Field(..., min_length=10, description="The text to be processed.")
min_length: int = Field(30, ge=5, le=300, description="Minimum length of the summary.")
max_length: int = Field(130, ge=10, le=500, description="Maximum length of the summary.")
class SummarizeResponse(BaseModel):
summary: str
original_length_chars: int
original_length_words: int
summary_length_chars: int
summary_length_words: int
percentage_reduction: float
time_taken_seconds: float
summarization_model_used: str
class SummarizeResponseDetailed(SummarizeResponse):
entities_found: List[str]
tone: str
keywords: List[str]
ner_model_used: str
tone_model_used: str
keywords_model_used: str
# --- HELPER PARSING FUNCTIONS ---
def extract_entities(text: str) -> List[str]:
"""Extracts unique entity names from the NER pipeline."""
try:
raw_entities = text_ner(text)
return list(set([ent['word'].strip() for ent in raw_entities if len(ent['word']) > 1]))
except Exception as e:
logger.warning(f"NER extraction failed: {str(e)}")
return []
def analyze_tone(text: str) -> str:
"""Extracts dominant tone label."""
try:
raw_tones = text_tone(text, candidate_labels=candidate_tones)
return raw_tones['labels'][0] if raw_tones and 'labels' in raw_tones else "unknown"
except Exception as e:
logger.warning(f"Tone analysis failed: {str(e)}")
return "unknown"
def extract_keywords(text: str) -> List[str]:
"""Extracts and cleans up keyphrases, removing sub-word token hashtags."""
try:
raw_keywords = text_keywords(text)
cleaned_phrases = set()
for item in raw_keywords:
phrase = item['word'].replace("##", "").strip()
if len(phrase) > 2:
cleaned_phrases.add(phrase)
return list(cleaned_phrases)
except Exception as e:
logger.warning(f"Keyword extraction failed: {str(e)}")
return []
# --- ENDPOINTS ---
@app.get("/api/health")
def health_check():
return {
"status": "healthy" if text_summarizer is not None else "loading/unhealthy",
"model": SUMMARIZATION_MODEL_NAME
}
@app.post("/api/tokenize", response_model=TokenizeResponse)
async def tokenize_text(request: TokenizeRequest):
global text_summarizer
if text_summarizer is None:
raise HTTPException(status_code=503, detail="Model pipeline is currently unavailable.")
try:
if not request.text.strip():
return TokenizeResponse(tokens=[], token_count=0)
input_ids = text_summarizer.tokenizer.encode(request.text, add_special_tokens=False)
tokens_list = []
for idx, token_id in enumerate(input_ids):
token_text = text_summarizer.tokenizer.decode([token_id])
tokens_list.append(TokenInfo(
id=token_id,
text=token_text,
color_index=idx % 5
))
return TokenizeResponse(
tokens=tokens_list,
token_count=len(input_ids)
)
except Exception as e:
logger.error(f"Tokenization failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Tokenization failed: {str(e)}")
@app.post("/api/summarize", response_model=SummarizeResponse)
async def summarize_text(request: SummarizeRequest):
global text_summarizer
if text_summarizer is None:
raise HTTPException(status_code=503, detail="Model pipeline is currently unavailable.")
if request.max_length <= request.min_length:
raise HTTPException(status_code=400, detail="max_length must be strictly greater than min_length.")
words_in_input = len(request.text.split())
# Calculate token count first
try:
tokens_count = len(text_summarizer.tokenizer.encode(request.text))
except Exception as e:
logger.error(f"Failed to count tokens: {str(e)}")
tokens_count = int(words_in_input * 1.3) # Fallback estimation
if tokens_count < 10:
raise HTTPException(status_code=400, detail="Input text is too short. Please provide at least 10 tokens.")
if tokens_count > 1024:
raise HTTPException(
status_code=400,
detail=f"Input text is too long ({tokens_count} tokens). Please shorten text to under 1024 tokens."
)
start_time = time.time()
try:
# Clamp based on token count
adjusted_max_length = min(request.max_length, tokens_count)
adjusted_min_length = min(request.min_length, max(5, adjusted_max_length - 5))
# Absolute safeguard for short inputs to prevent Hugging Face errors
if adjusted_max_length <= adjusted_min_length:
adjusted_min_length = max(1, adjusted_max_length - 1)
result = text_summarizer(
request.text,
min_length=adjusted_min_length,
max_length=adjusted_max_length,
do_sample=False
)
summary_text = result[0]["summary_text"]
duration = time.time() - start_time
words_in_summary = len(summary_text.split())
chars_original = len(request.text)
chars_summary = len(summary_text)
reduction = 100.0 * (1.0 - (chars_summary / chars_original)) if chars_original > 0 else 0.0
return SummarizeResponse(
summary=summary_text,
original_length_chars=chars_original,
original_length_words=words_in_input,
summary_length_chars=chars_summary,
summary_length_words=words_in_summary,
percentage_reduction=round(reduction, 2),
time_taken_seconds=round(duration, 2),
summarization_model_used=SUMMARIZATION_MODEL_NAME
)
except Exception as e:
logger.error(f"Inference error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}")
@app.post("/api/summarize-detailed", response_model=SummarizeResponseDetailed)
async def summarize_text_detailed(request: SummarizeRequest):
base_result = await summarize_text(request)
extracted_entities = extract_entities(request.text)
tone = analyze_tone(request.text)
extracted_keywords = extract_keywords(request.text)
return SummarizeResponseDetailed(
**base_result.model_dump(),
entities_found=extracted_entities,
tone=tone,
keywords=extracted_keywords,
ner_model_used=NER_MODEL_NAME,
tone_model_used=TONE_MODEL_NAME,
keywords_model_used=KEYWORDS_MODEL_NAME
)
if __name__ == "__main__":
import uvicorn
# Assumes filename is main.py
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)