feat: implement news aggregator API with conventional commits
- Add FastAPI application with complete router structure - Implement search, articles, ask, feedback, and health endpoints - Add comprehensive Pydantic schemas for API requests/responses - Include stub service implementations for all business logic - Add full test suite with pytest-asyncio integration - Configure conventional commits enforcement via git hooks - Add project documentation and contribution guidelines - Support both OpenAI and Gemini LLM integration options
This commit is contained in:
5
apps/api/src/news_api/__init__.py
Normal file
5
apps/api/src/news_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""News API package exposing the FastAPI application factory."""
|
||||
|
||||
from .main import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
21
apps/api/src/news_api/config.py
Normal file
21
apps/api/src/news_api/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Application configuration via Pydantic settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime configuration for the API service."""
|
||||
|
||||
app_name: str = "News Aggregator API"
|
||||
default_search_mode: str = "hybrid"
|
||||
max_page_size: int = 8
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="NEWS_API_", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached settings instance to avoid re-parsing env vars."""
|
||||
|
||||
return Settings()
|
||||
22
apps/api/src/news_api/main.py
Normal file
22
apps/api/src/news_api/main.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .config import get_settings
|
||||
from .routers import register
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application instance."""
|
||||
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
summary="Hybrid search and conversational answers over Reuters articles.",
|
||||
)
|
||||
register(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
18
apps/api/src/news_api/routers/__init__.py
Normal file
18
apps/api/src/news_api/routers/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Router registration utilities."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import articles, ask, feedback, health, search
|
||||
|
||||
|
||||
def register(api: APIRouter) -> None:
|
||||
"""Attach all endpoint groups to the provided router or application."""
|
||||
|
||||
api.include_router(health.router)
|
||||
api.include_router(search.router)
|
||||
api.include_router(articles.router)
|
||||
api.include_router(ask.router)
|
||||
api.include_router(feedback.router)
|
||||
|
||||
|
||||
__all__ = ["register"]
|
||||
15
apps/api/src/news_api/routers/articles.py
Normal file
15
apps/api/src/news_api/routers/articles.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Article metadata endpoints."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..schemas import ArticleResponse
|
||||
from ..services.articles import fetch_article
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["articles"])
|
||||
|
||||
|
||||
@router.get("/articles/{article_id}", response_model=ArticleResponse)
|
||||
def get_article(article_id: str) -> ArticleResponse:
|
||||
"""Return metadata for a specific article."""
|
||||
|
||||
return fetch_article(article_id)
|
||||
15
apps/api/src/news_api/routers/ask.py
Normal file
15
apps/api/src/news_api/routers/ask.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Conversational answer endpoint."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..schemas import AskRequest, AskResponse
|
||||
from ..services.ask import answer_question
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["ask"])
|
||||
|
||||
|
||||
@router.post("/ask", response_model=AskResponse)
|
||||
def ask(payload: AskRequest) -> AskResponse:
|
||||
"""Return an answer to the user query."""
|
||||
|
||||
return answer_question(payload)
|
||||
15
apps/api/src/news_api/routers/feedback.py
Normal file
15
apps/api/src/news_api/routers/feedback.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Feedback intake endpoint."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..schemas import FeedbackRequest, FeedbackResponse
|
||||
from ..services.feedback import record_feedback
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["feedback"])
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=FeedbackResponse)
|
||||
def feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||
"""Accept thumbs up/down feedback for later processing."""
|
||||
|
||||
return record_feedback(payload)
|
||||
19
apps/api/src/news_api/routers/health.py
Normal file
19
apps/api/src/news_api/routers/health.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Health endpoints for Kubernetes probes."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
"""Return a simple ok response for liveness probes."""
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
def ready() -> dict[str, str]:
|
||||
"""Return ready until upstream dependencies are integrated."""
|
||||
|
||||
return {"status": "ready"}
|
||||
22
apps/api/src/news_api/routers/search.py
Normal file
22
apps/api/src/news_api/routers/search.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Search endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..schemas import SearchMode, SearchResponse
|
||||
from ..services.search import perform_search
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["search"])
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query("", description="User supplied search query"),
|
||||
mode: SearchMode | None = Query(None, description="Search mode override"),
|
||||
page: int = Query(1, ge=1, description="1-indexed page number"),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> SearchResponse:
|
||||
"""Return hybrid search results (stubbed until storage wiring lands)."""
|
||||
|
||||
chosen_mode = mode or SearchMode(settings.default_search_mode)
|
||||
return perform_search(q, chosen_mode, page, settings.max_page_size)
|
||||
106
apps/api/src/news_api/schemas.py
Normal file
106
apps/api/src/news_api/schemas.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Pydantic models for API requests and responses."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class SearchMode(str, Enum):
|
||||
"""Supported search modes."""
|
||||
|
||||
HYBRID = "hybrid"
|
||||
KEYWORD = "keyword"
|
||||
SEMANTIC = "semantic"
|
||||
|
||||
|
||||
class SourceBadge(BaseModel):
|
||||
"""Represents a human friendly badge for a source."""
|
||||
|
||||
name: str
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
"""Citation metadata for an answer."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Single search result card."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
snippet: str
|
||||
canonical_url: HttpUrl = Field(..., description="Canonical link to the source article")
|
||||
published_at: datetime
|
||||
score: float = Field(..., ge=0)
|
||||
badges: List[SourceBadge]
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Response envelope for search requests."""
|
||||
|
||||
query: str
|
||||
mode: SearchMode
|
||||
page: int = Field(..., ge=1)
|
||||
results: List[SearchResult]
|
||||
|
||||
|
||||
class ArticleResponse(BaseModel):
|
||||
"""Metadata and summary for a single article."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
snippet: str
|
||||
summary: Optional[str]
|
||||
canonical_url: HttpUrl = Field(..., description="Canonical link to the source article")
|
||||
published_at: datetime
|
||||
authors: List[str]
|
||||
topics: List[str] = []
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
"""Request payload for the conversational endpoint."""
|
||||
|
||||
query: str
|
||||
conversation_id: Optional[str] = None
|
||||
|
||||
|
||||
class AnswerSentence(BaseModel):
|
||||
"""Single sentence in an answer with citations."""
|
||||
|
||||
text: str
|
||||
citations: List[str] # citation ids referencing SearchResult IDs
|
||||
|
||||
|
||||
class AskResponse(BaseModel):
|
||||
"""Answer payload with citations."""
|
||||
|
||||
answer: List[AnswerSentence]
|
||||
citations: List[Citation]
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class FeedbackVerdict(str, Enum):
|
||||
"""Allowed feedback verdicts."""
|
||||
|
||||
UP = "up"
|
||||
DOWN = "down"
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
"""Request payload for feedback submission."""
|
||||
|
||||
query: str
|
||||
answer_id: Optional[str] = None
|
||||
verdict: FeedbackVerdict
|
||||
comment: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class FeedbackResponse(BaseModel):
|
||||
"""Acknowledgement response for feedback."""
|
||||
|
||||
status: str
|
||||
received_at: datetime
|
||||
20
apps/api/src/news_api/services/articles.py
Normal file
20
apps/api/src/news_api/services/articles.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Article retrieval stubs."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..schemas import ArticleResponse
|
||||
|
||||
|
||||
def fetch_article(article_id: str) -> ArticleResponse:
|
||||
"""Return static article metadata while the DB layer is stubbed."""
|
||||
|
||||
return ArticleResponse(
|
||||
id=article_id,
|
||||
title="Stubbed Reuters piece",
|
||||
snippet="An ingest worker will eventually populate this field with live data.",
|
||||
summary="This summary is generated by the summarizer worker during ingestion.",
|
||||
canonical_url="https://www.reuters.com/world/stubbed-piece-2024-01-01/",
|
||||
published_at=datetime(2024, 1, 1, 0, 0, 0),
|
||||
authors=["Reuters Staff"],
|
||||
topics=["world"],
|
||||
)
|
||||
35
apps/api/src/news_api/services/ask.py
Normal file
35
apps/api/src/news_api/services/ask.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Conversational answer scaffolding."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from ..schemas import (
|
||||
AnswerSentence,
|
||||
AskRequest,
|
||||
AskResponse,
|
||||
Citation,
|
||||
)
|
||||
from .search import generate_conversation_id
|
||||
|
||||
|
||||
def answer_question(payload: AskRequest) -> AskResponse:
|
||||
"""Produce a placeholder answer that references stub citations."""
|
||||
|
||||
conversation_id = payload.conversation_id or generate_conversation_id()
|
||||
sentences: List[AnswerSentence] = [
|
||||
AnswerSentence(
|
||||
text=(
|
||||
"This is a placeholder answer generated by the API skeleton; "
|
||||
"it will be replaced once the summarizer worker is connected."
|
||||
),
|
||||
citations=["stub-1"],
|
||||
)
|
||||
]
|
||||
citations = [
|
||||
Citation(
|
||||
id="stub-1",
|
||||
title="Stubbed Reuters piece",
|
||||
url="https://www.reuters.com/world/stubbed-piece-2024-01-01/",
|
||||
)
|
||||
]
|
||||
return AskResponse(answer=sentences, citations=citations, conversation_id=conversation_id)
|
||||
12
apps/api/src/news_api/services/feedback.py
Normal file
12
apps/api/src/news_api/services/feedback.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Feedback persistence placeholder."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..schemas import FeedbackRequest, FeedbackResponse
|
||||
|
||||
|
||||
def record_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||
"""Return a simple acknowledgement until persistence is wired up."""
|
||||
|
||||
# A real implementation would enqueue this payload to Redis or persist to Postgres.
|
||||
return FeedbackResponse(status="queued", received_at=datetime.now(tz=timezone.utc))
|
||||
36
apps/api/src/news_api/services/search.py
Normal file
36
apps/api/src/news_api/services/search.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Stubbed search service that will later interface with PostgreSQL and pgvector."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
|
||||
from ..schemas import SearchMode, SearchResponse, SearchResult, SourceBadge
|
||||
|
||||
|
||||
def perform_search(query: str, mode: SearchMode, page: int, page_size: int) -> SearchResponse:
|
||||
"""Return a deterministic stub search response for scaffolding purposes."""
|
||||
|
||||
normalized_query = query.strip() or "latest news"
|
||||
# Provide a single deterministic card to unblock UI development.
|
||||
result_id = f"stub-{page}-{abs(hash(normalized_query)) % 10_000}"
|
||||
badges: List[SourceBadge] = [
|
||||
SourceBadge(name="Reuters", url="https://www.reuters.com"),
|
||||
]
|
||||
results = [
|
||||
SearchResult(
|
||||
id=result_id,
|
||||
title=f"Stubbed headline for '{normalized_query}'",
|
||||
snippet="This is placeholder snippet text until the ingest pipeline is ready.",
|
||||
canonical_url="https://www.reuters.com/world/europe/stubbed-headline-2024-01-01/",
|
||||
published_at=datetime(2024, 1, 1, 0, 0, 0),
|
||||
score=0.42,
|
||||
badges=badges,
|
||||
)
|
||||
]
|
||||
return SearchResponse(query=normalized_query, mode=mode, page=page, results=results)
|
||||
|
||||
|
||||
def generate_conversation_id() -> str:
|
||||
"""Return a predictable opaque identifier for conversations."""
|
||||
|
||||
return uuid4().hex
|
||||
Reference in New Issue
Block a user