File size: 6,500 Bytes
901e5fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933331c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
901e5fa
 
 
 
 
 
 
 
933331c
 
 
 
 
901e5fa
933331c
901e5fa
933331c
901e5fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933331c
 
 
901e5fa
933331c
901e5fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
189
190
191
192
193
194
195
196
197
198
199
200
201
# src/ai_agent/cli.py
from __future__ import annotations

import argparse
import os
import sys
import threading
import time
from dotenv import load_dotenv
import logging

load_dotenv()
log = logging.getLogger("ai_agent.cli")

from ai_agent.catalog.sync import sync_once
from ai_agent.utils.shutdown import register as _register_shutdown_hooks


def _ui_funcs():
    # Lazy import avoids loading agent/model modules for non-UI commands.
    from ai_agent.ui.app import (
        get_pipeline,
        refresh_ui_docs_from_index,
        launch,
        ensure_logging_initialized,
    )

    return get_pipeline, refresh_ui_docs_from_index, launch, ensure_logging_initialized


# --------------------------- catalog background refresher ---------------------------
def _background_refresh():
    """If SYNC_EVERY_HOURS > 0, refresh in the background while UI runs."""
    hours = float(os.getenv("SYNC_EVERY_HOURS", "0") or 0)

    if hours <= 0:
        log.info("[auto-refresh] disabled")
        return

    def _loop():
        # Startup already performs one sync in run_chat(); wait one full interval
        # before the first background refresh to avoid duplicate work.
        interval_s = max(60.0, hours * 3600.0)
        time.sleep(interval_s)

        while True:
            try:
                res = sync_once()
                log.info(
                    "[auto-refresh] %s → %s",
                    res.get("count", "?"),
                    res.get("jsonl_path"),
                )

                get_pipeline, refresh_ui_docs_from_index, _, _ = _ui_funcs()
                pipe = get_pipeline()

                if res.get("changed"):
                    ok = pipe.reload_index()
                    if ok:
                        log.info("[auto-refresh] reloaded FAISS index")
                        refresh_ui_docs_from_index()
                    else:
                        log.warning(
                            "[auto-refresh] reload failed; serving previous index"
                        )
                else:
                    log.info("[auto-refresh] catalog unchanged; FAISS not touched")
            except Exception:
                log.exception("[auto-refresh] error")
            try:
                time.sleep(interval_s)
            except Exception:
                time.sleep(3600.0)

    t = threading.Thread(target=_loop, daemon=True)
    t.start()


def _startup_sync_async():
    """Run the initial catalog sync + index reload in a daemon thread so it never

    blocks server/UI startup. The app comes up immediately (serving whatever

    index already exists, if any); recommendations populate once the potentially

    slow remote embedding finishes."""

    def _run():
        try:
            res = sync_once()
            log.info(
                "[startup-sync] %s → %s", res.get("count", "?"), res.get("jsonl_path")
            )
            if res.get("changed"):
                get_pipeline, refresh_ui_docs_from_index, _, _ = _ui_funcs()
                if get_pipeline().reload_index():
                    log.info("[startup-sync] reloaded FAISS index")
                    try:
                        refresh_ui_docs_from_index()
                    except Exception:
                        pass
                else:
                    log.warning("[startup-sync] reload failed; keeping current index")
            else:
                log.info("[startup-sync] catalog unchanged; keeping current index")
        except Exception:
            log.exception("[startup-sync] failed")

    threading.Thread(target=_run, daemon=True).start()


# --------------------------- custom tasks ---------------------------
def run_chat():
    """Launch the chat-based UI."""
    try:
        _, _, _, ensure_logging_initialized = _ui_funcs()
        ensure_logging_initialized()
        _register_shutdown_hooks()

        # Bring up the pipeline against any existing on-disk index without
        # blocking on a remote catalog sync (that runs in the background below),
        # so the UI is available immediately even on a cold start.
        get_pipeline, _, _, _ = _ui_funcs()
        get_pipeline()
    except Exception:
        log.exception("[startup] pipeline init failed")

    _startup_sync_async()
    _background_refresh()

    try:
        _, _, launch, _ = _ui_funcs()
        launch()
    except Exception:
        log.exception("[chat-launch] failed")
        raise


def run_sync():
    try:
        _register_shutdown_hooks()
        r = sync_once()
        log.info("[sync] %s → %s", r.get("count", "?"), r.get("jsonl_path"))
    except Exception:
        log.exception("[sync] failed")
        raise


def run_serve():
    """Launch the FastAPI backend with uvicorn.



    The FastAPI app reuses the same pipeline singleton as the Gradio path,

    so a one-time catalog sync keeps both surfaces consistent. That sync runs in

    a background thread so uvicorn binds the port immediately (a cold sync embeds

    the whole catalog remotely and can take minutes).

    """
    _startup_sync_async()
    _background_refresh()

    import uvicorn

    host = os.getenv("HOST", "0.0.0.0")
    port = int(os.getenv("PORT", "8000"))
    reload_flag = os.getenv("UVICORN_RELOAD", "0").lower() in ("1", "true", "yes", "on")
    log.info("Starting FastAPI on %s:%d (reload=%s)", host, port, reload_flag)
    uvicorn.run(
        "ai_agent.api.server:app",
        host=host,
        port=port,
        reload=reload_flag,
        proxy_headers=True,
        forwarded_allow_ips="*",
    )


# --------------------------- main entry ---------------------------
def main():
    p = argparse.ArgumentParser(description="AI Agent CLI")
    p.add_argument(
        "mode",
        choices=["chat", "sync", "serve"],
        help=(
            "'chat' launches the legacy Gradio UI; "
            "'sync' runs one catalog refresh; "
            "'serve' starts the FastAPI backend (used by the React frontend)."
        ),
    )
    args = p.parse_args()

    if args.mode == "chat":
        run_chat()
    elif args.mode == "sync":
        run_sync()
    elif args.mode == "serve":
        run_serve()
    else:
        p.print_help()
        sys.exit(f"Unsupported mode: {args.mode}")


if __name__ == "__main__":
    main()