diff --git a/app/api/v1/endpoints/attention.py b/app/api/v1/endpoints/attention.py index f44ec50..68cd109 100644 --- a/app/api/v1/endpoints/attention.py +++ b/app/api/v1/endpoints/attention.py @@ -228,11 +228,14 @@ async def admin_collect_gdelt( "/entity/{ticker}", response_model=EntityResolveResponse, summary="Get entity mapping for a ticker", - description=( - "Returns the stored entity mapping for a ticker: canonical name, Wikipedia title, " - "GDELT query string, and resolver confidence score.\n\n" - "Returns 404 if no mapping exists — run `POST /admin/resolve/{ticker}` first." - ), + description=""" + Returns the stored entity mapping for a ticker: canonical name, Wikipedia title, + GDELT query string, and resolver confidence score. + + Returns **404** if no mapping exists — run `POST /admin/resolve/{ticker}` first. + + **Example**: `GET /attention/entity/AAPL` + """, ) async def get_entity( ticker: str, @@ -265,18 +268,34 @@ async def get_entity( "/event/{ticker}", response_model=EventAttentionResponse, summary="Get attention features for a ticker on an event date", - description=( - "Returns Wikipedia pageview spike/z-score and GDELT news article counts " - "for the given ticker centered on `event_date`.\n\n" - "**Wikipedia data** is collected on-demand if not in DB. " - "**GDELT data** is never collected on-demand — it must be pre-populated via " - "`POST /admin/collect/gdelt/{ticker}` (scheduler). " - "Check `news.gdelt_status` in the response to understand data availability:\n\n" - "- `collected` — GDELT was collected; counts are accurate (0 means genuinely no articles)\n" - "- `not_collected` — scheduler has not run for this date yet\n" - "- `not_available` — event date is before GDELT V2 coverage (2017-01-01)\n\n" - "If no entity mapping exists, resolution runs automatically before collection." - ), + description=""" + Returns Wikipedia pageview spike/z-score and GDELT news volume for a ticker + centered on a specific event date. Designed for event-driven backtesting. + + **Wikipedia signals** (collected on-demand): + - `wiki.views` — raw pageview count on `event_date` + - `wiki.spike_10d` — views / 10-day median baseline; >1 = above-average interest + - `wiki.zscore_20d` — standard-deviation units above 20-day mean + + **GDELT news signals** (pre-populated by scheduler only): + - `news.article_count_1d` — articles published on `event_date` + - `news.article_count_3d` — articles in `event_date ± 1 day` window + - `news.unique_domains_3d` — distinct publisher domains in that window + - `news.gdelt_status` — data availability flag: + - `collected` — scheduler ran; counts are accurate (0 = genuinely no articles) + - `not_collected` — scheduler has not run yet; use `POST /admin/collect/gdelt/{ticker}` + - `not_available` — event date is before GDELT V2 coverage (2017-01-01) + + **Auto-resolution**: if no entity mapping exists, resolution runs automatically first. + + **Examples**: + - `GET /attention/event/AAPL?event_date=2024-02-01` — Q1 earnings day attention + - `GET /attention/event/NVDA?event_date=2024-05-22` — post-earnings spike + """, + responses={ + 404: {"description": "Ticker not found or entity resolution failed"}, + 500: {"description": "Feature materialization or collection error"}, + }, ) async def get_event_attention( ticker: str, diff --git a/app/api/v1/endpoints/database.py b/app/api/v1/endpoints/database.py index 36b2239..0bb08f2 100644 --- a/app/api/v1/endpoints/database.py +++ b/app/api/v1/endpoints/database.py @@ -17,7 +17,18 @@ from app.schemas.financial import DataSource router = APIRouter() logger = logging.getLogger(__name__) -@router.get("/stats") +@router.get( + "/stats", + summary="Database record counts and date ranges", + description=""" + Returns aggregate statistics across all core tables: + + - `companies` — total companies, how many have financial/price data + - `financial_data` — total records, real vs estimated, date range, breakdown by source + - `price_data` — total records, date range, list of tickers + - `calculated_metrics` — total records and date range + """, +) async def get_database_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: """ 데이터베이스 통계 정보를 반환합니다. @@ -156,7 +167,7 @@ async def get_database_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, An detail=f"Failed to fetch database statistics: {str(e)}" ) -@router.get("/health") +@router.get("/health", summary="Database connection health check") async def get_database_health(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: """ 데이터베이스 연결 상태를 확인합니다. @@ -178,7 +189,7 @@ async def get_database_health(db: AsyncSession = Depends(get_db)) -> Dict[str, A detail=f"Database connection failed: {str(e)}" ) -@router.get("/tables") +@router.get("/tables", summary="Table row counts for all core tables") async def get_table_info(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: """ 데이터베이스 테이블 정보를 반환합니다. @@ -213,7 +224,7 @@ async def get_table_info(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: detail=f"Failed to fetch table info: {str(e)}" ) -@router.post("/cleanup/duplicates") +@router.post("/cleanup/duplicates", summary="Remove duplicate financial and metrics records") async def cleanup_duplicate_records(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]: """ Remove duplicate financial and metrics records, keeping the most recent real data. @@ -263,7 +274,7 @@ async def cleanup_duplicate_records(db: AsyncSession = Depends(get_db)) -> Dict[ detail=f"Failed to cleanup duplicates: {str(e)}" ) -@router.get("/tickers") +@router.get("/tickers", summary="List tickers available in the database") async def get_available_tickers(db: AsyncSession = Depends(get_db)) -> Dict[str, List[str]]: """ 사용 가능한 종목 목록을 반환합니다. @@ -306,7 +317,18 @@ async def get_available_tickers(db: AsyncSession = Depends(get_db)) -> Dict[str, # ETF persisted data browsing # ========================== -@router.get("/etf/snapshots") +@router.get( + "/etf/snapshots", + summary="List persisted ETF holdings snapshots", + description=""" + Browse ETF holdings snapshots stored in the database. Each snapshot represents + the portfolio as reported in a SEC 13-F filing. + + Filter by `ticker`, `start_date`, `end_date`. Results are ordered by snapshot date (newest first). + + **Example**: `GET /database/etf/snapshots?ticker=SPY&limit=10` + """, +) async def list_etf_snapshots( ticker: str | None = None, start_date: str | None = None, @@ -366,7 +388,7 @@ async def list_etf_snapshots( raise HTTPException(status_code=500, detail="Failed to list ETF snapshots") -@router.get("/etf/snapshot/{snapshot_id}") +@router.get("/etf/snapshot/{snapshot_id}", summary="Get ETF snapshot with full holdings list") async def get_etf_snapshot(snapshot_id: str, db: AsyncSession = Depends(get_db)): try: from uuid import UUID @@ -411,7 +433,18 @@ async def get_etf_snapshot(snapshot_id: str, db: AsyncSession = Depends(get_db)) # Financial records browsing list # ============================== -@router.get("/financial/records") +@router.get( + "/financial/records", + summary="Browse raw financial data records", + description=""" + List raw financial data rows from the `financial_data` table. + + Supports filtering by `ticker`, `period_type` (`quarterly`/`annual`), + `start_date`, and `end_date`. Results ordered by `period_date` descending. + + **Example**: `GET /database/financial/records?ticker=AAPL&period_type=quarterly&limit=8` + """, +) async def list_financial_records( ticker: str | None = None, period_type: str | None = None, diff --git a/app/api/v1/endpoints/etf.py b/app/api/v1/endpoints/etf.py index c68ec4d..58f6bc7 100644 --- a/app/api/v1/endpoints/etf.py +++ b/app/api/v1/endpoints/etf.py @@ -33,7 +33,22 @@ class ETFHoldingsOut(BaseModel): error: Optional[str] = None -@router.get("/holdings/{ticker}", response_model=ETFHoldingsOut) +@router.get( + "/holdings/{ticker}", + response_model=ETFHoldingsOut, + summary="Get ETF portfolio holdings", + description=( + "Fetch the constituent holdings of an ETF (e.g., SPY, QQQ, IWM). " + "Data is sourced from SEC 13-F filings and cached for 1 hour.\n\n" + "Use `top_n` to limit to the N largest positions, or `top_percentage` to return " + "the minimal set of holdings that covers X% of the portfolio (e.g., `top_percentage=0.8` " + "for the holdings making up 80% of the ETF).\n\n" + "**Examples**:\n" + "- `GET /etf/holdings/SPY` — all holdings\n" + "- `GET /etf/holdings/QQQ?top_n=10` — top 10 positions\n" + "- `GET /etf/holdings/IWM?top_percentage=0.5` — holdings covering 50% of portfolio" + ), +) @with_cache(namespace="etf:holdings", ttl=3600, key_params=["ticker", "as_of_date", "top_n", "top_percentage"]) async def get_etf_holdings( ticker: str, @@ -90,7 +105,16 @@ class RefreshMapsOut(BaseModel): etf_rows: int = Field(...) -@router.post("/admin/refresh-maps", response_model=RefreshMapsOut) +@router.post( + "/admin/refresh-maps", + response_model=RefreshMapsOut, + summary="Refresh ETF CIK and CUSIP mapping tables", + description=( + "Re-fetches and upserts the ETF→CIK and CUSIP→ticker mapping tables from SEC data. " + "Run this when new ETFs need to be supported. Returns the number of rows updated." + ), + tags=["etf"], +) async def refresh_etf_maps(db: AsyncSession = Depends(get_db)): refreshed = await etf_loader_service.refresh_all(db) return RefreshMapsOut(**refreshed) diff --git a/app/api/v1/endpoints/filings.py b/app/api/v1/endpoints/filings.py index 30abae9..2379ef9 100644 --- a/app/api/v1/endpoints/filings.py +++ b/app/api/v1/endpoints/filings.py @@ -31,7 +31,17 @@ router = APIRouter() logger = logging.getLogger("app.api.v1.filings") -@router.get("/search/{ticker}", response_model=FilingSearchResponse) +@router.get( + "/search/{ticker}", + response_model=FilingSearchResponse, + summary="Search SEC filings for a ticker", + description=( + "Search SEC filings for the given ticker. Supported form types: **8-K, 6-K, 20-F, 40-F**.\n\n" + "Auto-indexes filings from EDGAR on first request (or when `force_refresh=true`). " + "Results are cached for 1 hour.\n\n" + "**Example**: `GET /filings/search/AAPL?form_type=8-K&limit=10`" + ), +) @with_cache(namespace="filings:search", ttl=3600, key_params=["ticker", "form_type", "start_date", "end_date", "limit", "offset"]) async def search_filings( ticker: str, @@ -129,7 +139,17 @@ async def search_filings( ) -@router.get("/documents/{accession_number}", response_model=FilingDocumentListResponse) +@router.get( + "/documents/{accession_number}", + response_model=FilingDocumentListResponse, + summary="List documents in a SEC filing", + description=( + "List all documents attached to a SEC filing by accession number.\n\n" + "Returns filename, document type, size, and SEC URL for each document. " + "Cached for 24 hours.\n\n" + "**Example**: `GET /filings/documents/0001193125-24-123456`" + ), +) @with_cache(namespace="filings:documents", ttl=86400, key_params=["accession_number"]) async def get_filing_documents( accession_number: str, @@ -158,7 +178,18 @@ async def get_filing_documents( ) -@router.get("/exhibit/{accession_number}", response_model=ExhibitContentResponse) +@router.get( + "/exhibit/{accession_number}", + response_model=ExhibitContentResponse, + summary="Extract exhibit content from a filing", + description=( + "Extract the text content of a specific exhibit (e.g., press release **EX-99.1**) " + "from a SEC filing.\n\n" + "Returns the full text content along with content type, filename, and SEC URL. " + "404 responses are negative-cached for 1 hour. Cached for 24 hours.\n\n" + "**Example**: `GET /filings/exhibit/0001193125-24-123456?exhibit_type=EX-99.1`" + ), +) @with_cache(namespace="filings:exhibit", ttl=86400, key_params=["accession_number", "exhibit_type"]) async def get_exhibit_content( accession_number: str, @@ -200,7 +231,21 @@ async def get_exhibit_content( ) -@router.post("/search/bulk", response_model=BulkFilingSearchResponse) +@router.post( + "/search/bulk", + response_model=BulkFilingSearchResponse, + summary="Bulk search SEC filings for multiple tickers", + description=( + "Search SEC filings for up to many tickers in a single request. " + "Auto-indexes from EDGAR for any ticker not yet in the database.\n\n" + "**Timeout**: 600 seconds. Each ticker is processed concurrently.\n\n" + "**Example body**:\n" + "```json\n" + '{"tickers": ["AAPL", "MSFT", "NVDA"], "form_type": "8-K", ' + '"start_date": "2024-01-01", "limit_per_ticker": 5}\n' + "```" + ), +) @with_cache(namespace="filings:search:bulk", ttl=3600, key_params=["request"]) async def search_filings_bulk( request: BulkFilingSearchRequest, @@ -288,7 +333,19 @@ async def search_filings_bulk( ) -@router.post("/exhibit/bulk", response_model=BulkExhibitResponse) +@router.post( + "/exhibit/bulk", + response_model=BulkExhibitResponse, + summary="Bulk fetch exhibit content", + description=( + "Fetch exhibit content for multiple accession numbers in one request. " + "Up to 4 concurrent fetches; max 300 second timeout.\n\n" + "**Example body**:\n" + "```json\n" + '{"items": [{"accession_number": "0001193125-24-123456", "exhibit_type": "EX-99.1"}]}\n' + "```" + ), +) async def get_exhibit_bulk( request: BulkExhibitRequest, db: AsyncSession = Depends(get_db), diff --git a/app/api/v1/endpoints/fred.py b/app/api/v1/endpoints/fred.py index fcee92f..9458829 100644 --- a/app/api/v1/endpoints/fred.py +++ b/app/api/v1/endpoints/fred.py @@ -20,7 +20,7 @@ logger = logging.getLogger("app.api.v1.fred") # Use /proxy/{endpoint} instead for all FRED API access -@router.get("/stats/usage") +@router.get("/stats/usage", summary="FRED API usage statistics and cache performance") async def get_fred_usage_stats( days: int = Query(7, ge=1, le=30, description="Number of days to include in stats"), use_proxy_stats: bool = Query(True, description="Use enhanced proxy service statistics"), @@ -124,7 +124,7 @@ async def get_fred_usage_stats( # Removed: /search endpoint - use /proxy/series/search instead -@router.get("/proxy/{endpoint:path}") +@router.get("/proxy/{endpoint:path}", summary="Universal FRED API proxy") async def fred_proxy_endpoint( endpoint: str, db: AsyncSession = Depends(get_db), @@ -305,7 +305,7 @@ async def fred_proxy_endpoint( ) -@router.get("/endpoints") +@router.get("/endpoints", summary="List supported FRED API endpoints") async def get_supported_fred_endpoints(): """ Get list of supported FRED API endpoints diff --git a/app/api/v1/endpoints/news.py b/app/api/v1/endpoints/news.py index 0575ef7..ca2a6b8 100644 --- a/app/api/v1/endpoints/news.py +++ b/app/api/v1/endpoints/news.py @@ -75,7 +75,24 @@ class NewsSocialResponse(BaseModel): summary: NewsSocialSummarySchema -@router.get("/{ticker}", response_model=NewsSocialResponse) +@router.get( + "/{ticker}", + response_model=NewsSocialResponse, + summary="Get news and social media for a ticker", + description=""" + Fetch recent news articles and social media posts for a ticker from multiple sources. + + **News sources**: Yahoo Finance, NewsAPI + **Social sources**: Reddit (r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/ValueInvesting) + + Both sources are fetched in parallel. Results are deduplicated and ranked by relevance. + Cached for **10 minutes**. + + **Examples**: + - `GET /news/AAPL` — last 7 days, up to 20 articles + 15 posts + - `GET /news/TSLA?days_back=14&max_articles=50&include_social=false` — news-only, 2 weeks + """, +) @with_cache(namespace="news:full", ttl=600, key_params=["ticker", "days_back", "max_articles", "max_social_posts", "include_social"]) async def get_ticker_news_and_social( ticker: str, @@ -148,7 +165,19 @@ async def get_ticker_news_and_social( ) -@router.get("/{ticker}/news-only", response_model=NewsOnlyResponse) +@router.get( + "/{ticker}/news-only", + response_model=NewsOnlyResponse, + summary="Get news articles for a ticker (no social media)", + description=""" + Faster endpoint that returns only news articles, skipping social media API calls. + + **Sources**: Yahoo Finance, NewsAPI + Cached for **10 minutes**. + + **Example**: `GET /news/NVDA/news-only?days_back=3&max_articles=30` + """, +) @with_cache(namespace="news:news-only", ttl=600, key_params=["ticker", "days_back", "max_articles"]) async def get_ticker_news_only( ticker: str, @@ -217,7 +246,20 @@ async def get_ticker_news_only( ) -@router.get("/{ticker}/social-only", response_model=SocialOnlyResponse) +@router.get( + "/{ticker}/social-only", + response_model=SocialOnlyResponse, + summary="Get social media posts for a ticker", + description=""" + Returns only Reddit posts for a ticker, skipping news API calls. + + **Subreddits**: r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, + r/StockMarket, r/ValueInvesting, r/financialindependence + Cached for **10 minutes**. + + **Example**: `GET /news/GME/social-only?days_back=3&max_social_posts=30` + """, +) @with_cache(namespace="news:social-only", ttl=600, key_params=["ticker", "days_back", "max_social_posts"]) async def get_ticker_social_only( ticker: str, diff --git a/app/api/v1/endpoints/overlay.py b/app/api/v1/endpoints/overlay.py index 433f206..6a37c50 100644 --- a/app/api/v1/endpoints/overlay.py +++ b/app/api/v1/endpoints/overlay.py @@ -14,7 +14,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, and_ +from sqlalchemy import select, desc, and_, func from app.core.config import settings from app.core.database import get_db @@ -226,9 +226,26 @@ async def get_top_movers( raise HTTPException(status_code=503, detail="Overlay feature is disabled") cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + + # Get the latest record per symbol, then rank by score + latest_per_symbol = ( + select( + OverlayFeatureRecord.symbol, + func.max(OverlayFeatureRecord.as_of_ts).label("max_ts"), + ) + .where(OverlayFeatureRecord.as_of_ts >= cutoff) + .group_by(OverlayFeatureRecord.symbol) + .subquery() + ) result = await db.execute( select(OverlayFeatureRecord) - .where(OverlayFeatureRecord.as_of_ts >= cutoff) + .join( + latest_per_symbol, + and_( + OverlayFeatureRecord.symbol == latest_per_symbol.c.symbol, + OverlayFeatureRecord.as_of_ts == latest_per_symbol.c.max_ts, + ), + ) .order_by(desc(OverlayFeatureRecord.overlay_score)) .limit(limit) ) @@ -269,9 +286,9 @@ async def admin_health(db: AsyncSession = Depends(get_db)): ) last_job = result.scalars().first() - # Per-source status + # Per-job-type status (collect_all runs all sources; feature_build computes scores) sources = [] - for source_name in ["yahoo_rss", "wikimedia", "youtube", "google_trends", "collect_all", "feature_build"]: + for source_name in ["collect_all", "feature_build"]: result_s = await db.execute( select(OverlayJobLog) .where(OverlayJobLog.job_type == source_name) @@ -312,11 +329,22 @@ async def trigger_pipeline(): asyncio.create_task(_run()) return TriggerPipelineResponse( status="triggered", - message="Overlay pipeline started in background", + message="Overlay pipeline started in background (seeds topic maps, collects data, builds features)", job_ids=[], ) +@router.post( + "/admin/seed-topics", + summary="Seed ThemeTopicMap with default topic mappings", + tags=["overlay-admin"], +) +async def seed_topics(db: AsyncSession = Depends(get_db)): + """Create default ThemeTopicMap entries for all TOP_50_SYMBOLS (safe to re-run; skips existing).""" + inserted = await _pipeline.seed_topic_maps(db) + return {"status": "ok", "inserted": inserted, "message": f"Seeded {inserted} new topic mappings"} + + @router.get( "/admin/job-log", response_model=JobLogResponse, @@ -470,7 +498,7 @@ async def get_youtube( ) for e in sym_events ] - weighted_views = sum(e.view_count * e.channel_weight for e in events_24h) + weighted_views = sum((e.view_count or 0) * (e.channel_weight or 0.5) for e in events_24h) return YouTubeResponse( symbol=symbol, diff --git a/app/api/v1/endpoints/screener.py b/app/api/v1/endpoints/screener.py index a8a5b6b..f70a4df 100644 --- a/app/api/v1/endpoints/screener.py +++ b/app/api/v1/endpoints/screener.py @@ -16,7 +16,7 @@ router = APIRouter() logger = logging.getLogger("app.api.v1.screener") -@router.get("/stocks") +@router.get("/stocks", summary="Screen stocks by financial criteria") @with_cache( namespace="screener:stocks", ttl=300, @@ -128,7 +128,7 @@ async def screen_stocks( ) -@router.get("/fields") +@router.get("/fields", summary="Available screener filter options and valid values") async def get_screener_fields(): """ Return metadata about available screener filter options. diff --git a/app/api/v1/endpoints/stocks.py b/app/api/v1/endpoints/stocks.py index b372a80..9df8eb3 100644 --- a/app/api/v1/endpoints/stocks.py +++ b/app/api/v1/endpoints/stocks.py @@ -85,7 +85,7 @@ async def get_index_constituents( ) -@router.get("/most-active") +@router.get("/most-active", summary="Most actively traded stocks by volume") @with_cache(namespace="stocks:most-active", ttl=3600, key_params=["limit"]) async def get_most_active_stocks( response: Response, @@ -184,7 +184,7 @@ async def get_most_active_stocks( ) -@router.get("/52-week-gainers") +@router.get("/52-week-gainers", summary="Top 52-week gaining stocks") async def get_52week_gainers( limit: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."), max_pages: Optional[int] = Query(3, ge=1, le=10, description="Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting.") @@ -280,7 +280,7 @@ async def get_52week_gainers( ) -@router.get("/trending") +@router.get("/trending", summary="Trending stocks combining most active and 52-week gainers") async def get_trending_stocks( n: Optional[int] = Query(500, ge=1, description="Total number of trending stocks to return after combining most active + gainers (default: 500)"), most_active_limit: Optional[int] = Query(None, ge=1, description="Number of most active stocks to include. If not specified, returns all available stocks (~170)."), diff --git a/app/main.py b/app/main.py index 8b2c38a..c0ced20 100644 --- a/app/main.py +++ b/app/main.py @@ -4,9 +4,9 @@ Main FastAPI application import os from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.responses import RedirectResponse from app.core.config import settings from app.api.v1.api import api_router @@ -62,120 +62,10 @@ app.add_middleware( # Include API router app.include_router(api_router, prefix=settings.API_PREFIX) -# Root documentation endpoint — auto-generated from OpenAPI schema -@app.get("/", response_class=HTMLResponse, include_in_schema=False) -async def root_documentation(request: Request): - try: - schema = request.app.openapi() - paths = schema.get("paths", {}) - api_prefix = settings.API_PREFIX - - # Collect all tags defined in schema (preserves order) - tag_order = [t["name"] for t in schema.get("tags", [])] - - # Group routes by first tag - from collections import defaultdict - tag_routes: dict = defaultdict(list) - untagged: list = [] - for path, methods in paths.items(): - for method, op in methods.items(): - if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"): - continue - tags = op.get("tags", []) - entry = { - "method": method.upper(), - "path": path, - "summary": op.get("summary", ""), - "deprecated": op.get("deprecated", False), - } - if tags: - tag_routes[tags[0]].append(entry) - else: - untagged.append(entry) - - # Build tag sections — respect schema tag order, then alphabetical remainder - all_tags = tag_order + sorted(t for t in tag_routes if t not in tag_order) - if untagged: - all_tags.append("other") - tag_routes["other"] = untagged - - method_colors = { - "GET": "#61affe", - "POST": "#49cc90", - "PUT": "#fca130", - "DELETE": "#f93e3e", - "PATCH": "#50e3c2", - } - - sections_html = "" - for tag in all_tags: - routes = tag_routes.get(tag, []) - if not routes: - continue - rows = "" - for r in routes: - color = method_colors.get(r["method"], "#999") - deprecated = " style='opacity:0.5;text-decoration:line-through'" if r["deprecated"] else "" - display_path = r["path"].removeprefix(api_prefix) - rows += ( - f"
{display_path}