File size: 6,438 Bytes
215df55
e465928
 
 
 
 
 
 
 
fed7eb0
874d7cd
e465928
215df55
d62044b
215df55
 
 
 
 
d62044b
 
215df55
d62044b
 
 
 
 
 
 
 
 
 
215df55
d62044b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215df55
d62044b
 
 
 
 
 
 
 
215df55
d62044b
 
 
215df55
 
 
 
 
 
 
874d7cd
 
 
 
 
 
 
 
 
 
 
215df55
 
fed7eb0
 
215df55
e465928
 
 
 
 
 
d62044b
e465928
 
 
 
 
 
 
d62044b
e465928
 
 
 
d62044b
6338f31
215df55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6338f31
 
 
215df55
 
d62044b
6338f31
d62044b
 
 
 
e465928
215df55
e465928
 
 
 
215df55
e465928
d62044b
fed7eb0
 
 
e465928
 
 
 
 
 
fed7eb0
e465928
215df55
fed7eb0
215df55
 
fed7eb0
 
 
e465928
215df55
e465928
 
 
215df55
e465928
 
 
 
 
 
 
874d7cd
e465928
215df55
e465928
 
 
215df55
fed7eb0
 
215df55
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# app/main.py
from __future__ import annotations

import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Any, Dict

from fastapi import FastAPI
from fastapi.responses import RedirectResponse

# ---- Early env load (HF_TOKEN, ADMIN_TOKEN, GITHUB_TOKEN, etc.) ----
def _load_env_file(paths: list[str]) -> None:
    """
    Load environment variables from the first existing path in `paths`.
    Prefer python-dotenv if present; otherwise use a tiny fallback parser.
    Does not override pre-existing env vars (e.g., Space Secrets).
    """
    logger = logging.getLogger("uvicorn.error")

    # 1) Try python-dotenv
    try:
        from dotenv import load_dotenv  # type: ignore
        for p in paths:
            if os.path.exists(p):
                load_dotenv(dotenv_path=p, override=False)
                logger.info("Loaded environment from %s", p)
                return
        logger.info("No .env file found in %s (skipping)", paths)
        return
    except Exception:
        # 2) Fallback minimal parser
        for p in paths:
            if not os.path.exists(p):
                continue
            try:
                with open(p, "r", encoding="utf-8") as f:
                    for raw in f:
                        line = raw.strip()
                        if not line or line.startswith("#"):
                            continue
                        if line.startswith("export "):
                            line = line[len("export ") :].strip()
                        if "=" not in line:
                            continue
                        key, val = line.split("=", 1)
                        key, val = key.strip(), val.strip()
                        # strip optional quotes
                        if (val.startswith('"') and val.endswith('"')) or (
                            val.startswith("'") and val.endswith("'")
                        ):
                            val = val[1:-1]
                        # do not clobber existing env (e.g., HF Secrets)
                        os.environ.setdefault(key, val)
                logger.info("Loaded environment from %s (fallback parser)", p)
                return
            except Exception as e:
                logger.warning("Failed loading env from %s: %s", p, e)

    logger.info("No .env loaded (none found / parsers failed)")

# Try common local locations. HF Spaces will rely on Secrets instead.
_load_env_file([".env", "configs/.env", ".env.local", "configs/.env.local"])


# ---- RAG bootstrap & warm-up ----
from .deps import get_settings
from .services.chat_service import get_retriever
from .core.rag.build import ensure_kb


# ---- Middlewares ----
try:
    from .middleware import attach_middlewares  # singular
except Exception:
    try:
        from .middlewares import attach_middlewares  # plural
    except Exception:
        def attach_middlewares(app: FastAPI) -> None:  # no-op fallback
            logging.getLogger("uvicorn.error").warning(
                "attach_middlewares not found; continuing without custom middlewares."
            )


# ---- Routers ----
from .routers import health, plan, chat

# Optional UI bundle (/, /chat, /dev)
try:
    from .ui import router as ui_router  # type: ignore
    HAS_UI = True
except Exception:  # pragma: no cover
    HAS_UI = False


TAGS_METADATA = [
    {"name": "Health", "description": "Liveness / readiness probes and basic service metadata."},
    {"name": "Planning", "description": "AI plan generation for Matrix Guardian (/v1/plan)."},
    {"name": "Chat", "description": "Lightweight RAG/Q&A about Matrix System (/v1/chat)."},
    {"name": "UI", "description": "Minimal web UI (Home, Chat, Dev) if enabled."},
]


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.started_at = time.time()
    app.state.version = os.getenv("APP_VERSION", "1.0.0")

    logger = logging.getLogger("uvicorn.error")

    # 1) Build KB on first boot (skips if already present)
    try:
        if ensure_kb(
            out_jsonl="data/kb.jsonl",
            config_path="configs/rag_sources.yaml",
            skip_if_exists=True,
        ):
            logger.info("KB ready at data/kb.jsonl")
        else:
            logger.warning("KB build produced no records; running LLM-only.")
    except Exception as e:
        logger.warning("KB build failed (%s); running LLM-only.", e)

    # 2) Warm up RAG retriever (indexes data/kb.jsonl if present)
    logger.info("Warming up RAG retriever...")
    get_retriever(get_settings())
    logger.info("RAG retriever is ready.")

    # 3) Boot log
    hf_token_present = bool(os.getenv("HF_TOKEN"))
    logger.info(
        "matrix-ai starting (version=%s, port=%s, hf_token_present=%s)",
        app.state.version,
        os.getenv("PORT", "7860"),
        "yes" if hf_token_present else "no",
    )

    try:
        yield
    finally:
        uptime = time.time() - getattr(app.state, "started_at", time.time())
        logger.info("matrix-ai shutting down (uptime=%.2fs)", uptime)


def create_app() -> FastAPI:
    app = FastAPI(
        title="matrix-ai",
        version=os.getenv("APP_VERSION", "1.0.0"),
        description="AI planning microservice for the Matrix EcoSystem",
        openapi_tags=TAGS_METADATA,
        docs_url="/docs",
        redoc_url=None,
        lifespan=lifespan,
    )

    # Middlewares (gzip, CORS, rate-limit, req-logs, etc.)
    attach_middlewares(app)

    # Core routers
    app.include_router(health.router, tags=["Health"])
    app.include_router(plan.router, prefix="/v1", tags=["Planning"])
    app.include_router(chat.router, prefix="/v1", tags=["Chat"])

    # UI (/, /chat, /dev). Your ui.py already defines "/" → /chat
    if HAS_UI:
        app.include_router(ui_router, tags=["UI"])
    else:
        # Minimal root so HF root probes pass even without UI
        @app.get("/", include_in_schema=False)
        async def root() -> Dict[str, Any]:
            return {
                "ok": True,
                "service": "matrix-ai",
                "version": app.version,
                "docs": "/docs",
                "endpoints": {"plan": "/v1/plan", "chat": "/v1/chat", "healthz": "/healthz"},
            }

        @app.get("/home", include_in_schema=False)
        async def home_redirect():
            return RedirectResponse(url="/docs", status_code=302)

    return app


app = create_app()