File size: 8,750 Bytes
3bacc1d
 
 
 
 
 
 
d8e7745
 
 
3bacc1d
 
 
 
079782d
 
 
 
 
3bacc1d
 
 
 
 
 
0e5fdb5
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e5fdb5
f873f92
3bacc1d
 
 
 
 
f873f92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bacc1d
d8e7745
 
49b0848
 
 
 
 
3bacc1d
 
 
 
 
 
 
 
 
0e5fdb5
 
 
 
 
3bacc1d
0e5fdb5
 
 
3bacc1d
 
 
f873f92
 
3bacc1d
 
f873f92
3bacc1d
0e5fdb5
d8e7745
3bacc1d
0e5fdb5
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f873f92
 
3bacc1d
 
 
 
0e5fdb5
3bacc1d
 
 
 
 
 
 
 
f873f92
 
3bacc1d
 
 
 
 
 
0e5fdb5
3bacc1d
 
 
 
f873f92
 
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
079782d
 
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Chat endpoint — v2 pilot (pr/5 Phase 2).

`POST /api/v2/chat/stream` is the v2 of the only FE→Python call. It is identical to
`POST /api/v1/chat/stream` except:
  - the request carries an explicit **`analysis_id`** (replacing v1's `room_id`). The
    two are the same session id today (`analysis_id == room_id`), so the warm,
    process-shared `ChatHandler` and the v1 cache/history helpers are reused unchanged.
  - the `done` event carries the assistant **`message_id`**. It is always minted
    Python-side and is **never accepted from the caller** (server-authoritative — it keys
    the future `/observability` lookup; open-Q #1 resolved). The FE reads it off `done`.

Only chat moves to v2; the tools group + observability stay on `/api/v1` (contract:
API_ENDPOINTS_RESTRUCTURE.md §1).

Persistence (DEV_PLAN #25 — done). Python is **read-only** on the Go-owned
`analyses_messages` table: it *reads* turn history (so multi-turn context works) but no
longer writes the user/AI turns — Go is the sole writer. This removes the double-write
that appeared when both Go and Python's stream persisted the same turn. Aligns chat with
`/tools/help`, which was already generative-only.
"""

import json
import uuid
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse

# Reuse the v1 chat machinery verbatim (warm ChatHandler + cache/history helpers) so
# v2 stays a thin field-rename over the same logic. Importing the module-private helpers
# is the established pattern here (handlers/help.py imports `_chat_handler` the same way).
from src.api.v1.chat import (
    _CACHEABLE_INTENTS,
    _chat_cache_key,
    _chat_handler,
    _fast_intent,
    cache_response,
    get_cached_response,
    load_history,
)
from src.db.postgres.connection import get_db
from src.db.redis.connection import get_redis
from src.middlewares.logging import get_logger, log_execution
from src.middlewares.rate_limit import limiter
from src.traceability import PostgresTraceabilityStore, TraceabilityScratchpad

logger = get_logger("chat_api_v2")

router = APIRouter(prefix="/api/v2", tags=["Chat"])

# Module-level store (mirrors the warm, process-shared `_chat_handler`): the greeting
# fast-path and cache-replay branches never enter `ChatHandler.handle`, so they write
# their (empty `chat`) traceability row directly here. KM-691.
_traceability_store = PostgresTraceabilityStore()


async def _save_empty_chat_trace(analysis_id: str, user_id: str, message_id: str) -> None:
    """Persist an empty `chat` traceability row for a turn that bypassed the handler
    (greeting / cache replay), so the FE's GET on `done` returns a payload, not a 404."""
    try:
        pad = TraceabilityScratchpad()
        pad.message_id = message_id
        pad.set_intent("chat")
        await _traceability_store.save(pad.build(analysis_id, user_id, message_id))
    except Exception as e:  # noqa: BLE001 — never break the reply on a trace slip
        logger.warning("traceability direct save failed", message_id=message_id, error=str(e))


def _mint_message_id() -> str:
    """Mint the assistant turn id. Server-authoritative — never accepted from the caller
    (it keys the GET /api/v1/traceability lookup). Returned on `done`; open-Q #1 resolved.

    A canonical UUID string, matching Go's `analyses_messages.id` shape, so the value stays
    format-compatible if we later swap to the real message-row id (still Python-minted now)."""
    return str(uuid.uuid4())


class ChatRequest(BaseModel):
    user_id: str
    analysis_id: str
    message: str


@router.post("/chat/stream")
# Rate limit per client IP. `slowapi` needs a Starlette `Request` param named
# `request`, so the JSON body moves to `body`. NOTE: if the FE reaches Python through
# the Go proxy, `get_remote_address` sees Go's IP (one bucket for everyone) — size the
# limit accordingly, or switch to a user-scoped key once identity is forwarded.
@limiter.limit("30/minute")
@log_execution(logger)
async def chat_stream(
    request: Request, body: ChatRequest, db: AsyncSession = Depends(get_db)
):
    """Chat endpoint with streaming response (v2 — keyed on `analysis_id`).

    SSE event sequence:
      1. sources  — always `[]` (KM-691): sources moved to GET /api/v1/traceability;
                    the stream stays text-only. Event kept for backward-compat.
      2. status   — slow-path progress pings (optional)
      3. chunk    — text fragments of the answer
      4. done     — {"message_id": "..."} for the traceability lookup
    """
    analysis_id = body.analysis_id
    message_id = _mint_message_id()
    redis = await get_redis()
    cache_key = _chat_cache_key(analysis_id, body.user_id, body.message)

    # v2 `done` always carries the turn id (v1 sent an empty `done`).
    done_event = {"event": "done", "data": json.dumps({"message_id": message_id})}

    # Redis cache hit (stateless `chat` intent only).
    cached = await get_cached_response(redis, cache_key)
    logger.info("cache check", cache_key=cache_key, cache_hit=cached is not None)
    if cached:
        logger.info("Returning cached response")
        cached_text = cached["response"]
        cached_sources = cached["sources"]

        async def stream_cached():
            yield {"event": "sources", "data": json.dumps(cached_sources)}
            for i in range(0, len(cached_text), 50):
                yield {"event": "chunk", "data": cached_text[i:i + 50]}
            yield done_event

        # Write the row BEFORE the stream so the FE's GET on `done` can't race a 404.
        await _save_empty_chat_trace(analysis_id, body.user_id, message_id)
        return EventSourceResponse(stream_cached())

    try:
        # Fast intent: greetings/farewells bypass the LLM entirely.
        direct = _fast_intent(body.message)
        if direct:
            await cache_response(redis, cache_key, direct, sources=[])

            async def stream_direct():
                yield {"event": "sources", "data": json.dumps([])}
                yield {"event": "chunk", "data": direct}
                yield done_event

            # Write the row BEFORE the stream so the FE's GET on `done` can't race a 404.
            await _save_empty_chat_trace(analysis_id, body.user_id, message_id)
            return EventSourceResponse(stream_direct())

        history = await load_history(db, analysis_id, limit=10)
        handler = _chat_handler

        async def stream_response():
            logger.info("stream_response started", analysis_id=analysis_id, user_id=body.user_id)
            full_response = ""
            sources: list[dict[str, Any]] = []
            effective_intent: str | None = None
            async for event in handler.handle(
                body.message, body.user_id, history,
                analysis_id=analysis_id, message_id=message_id,
            ):
                if event["event"] == "intent":
                    # consumed internally (not forwarded); gates caching below.
                    try:
                        effective_intent = json.loads(event["data"]).get("intent")
                    except (TypeError, ValueError, AttributeError):
                        effective_intent = None
                elif event["event"] == "sources":
                    try:
                        sources = json.loads(event["data"]) or []
                    except (TypeError, ValueError):
                        sources = []
                    yield event
                elif event["event"] == "chunk":
                    full_response += event["data"]
                    yield event
                elif event["event"] == "done":
                    # Only cache stateless `chat` replies (see _CACHEABLE_INTENTS).
                    if effective_intent in _CACHEABLE_INTENTS:
                        await cache_response(redis, cache_key, full_response, sources=sources)
                    # Persistence is Go's job now (DEV_PLAN #25): Python reads history but
                    # no longer writes turns, so Go stays the sole writer of analyses_messages.
                    yield done_event
                elif event["event"] == "status":
                    # slow-path progress: forward so the client shows activity.
                    yield event
                elif event["event"] == "error":
                    yield event
                    return

        return EventSourceResponse(stream_response())

    except Exception as e:
        logger.error("Chat failed", error=str(e))
        raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}") from e