File size: 12,218 Bytes
1086e43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""Ask endpoint: routes prompts through context router to local LLM."""

import json
from uuid import UUID
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_db, get_ask_limiter, get_ask_semaphore
from app.api.errors import DocumentNotFound, ModelUnavailable, NoLLMConfigured
from app.chat.orchestrator import handle_ask, handle_ask_stream
from app.core.logging import get_logger
from app.services import documents as doc_service
from app.database.repositories import chunks as chunk_repo
from app.database.repositories import conversations as conv_repo

logger = get_logger(__name__)

router = APIRouter()

# Hard cap on sub-thread nesting: main chat (layer 0) + 3 sub-chat layers.
# Tested up to L3; deeper nesting is disabled in the UI and rejected by /ask.
MAX_SUB_THREAD_DEPTH = 3


class AskPayload(BaseModel):
    query: str
    current_sequence_order: Optional[int] = None
    conversation_id: Optional[UUID] = None

    # Richer visible context (quality improvement)
    # Allows the model to "see" what the user currently has open in the paper viewer
    visible_sequence_orders: Optional[list[int]] = None   # e.g. [42, 43, 44, 45]
    focused_element: Optional[str] = None                 # "figure:7", "table:3", "architecture-diagram", etc.

    # Optional image attachments uploaded with this question (e.g. user drops a
    # screenshot of a figure into the chat). Base64-encoded raw bytes, no
    # `data:image/...;base64,` prefix. Passed through to the multimodal Ollama
    # request so vision-capable models like gemma4 can actually see the image.
    images_b64: Optional[list[str]] = None

    # === Sub-thread (nested tangent) support ===
    # parent_turn_id: the turn this message is a reply to inside a sub-thread.
    # thread_root_turn_id: the root of the sub-thread the user is currently in
    # (the original branching user turn). Used for correct history + compaction.
    parent_turn_id: Optional[UUID] = None
    thread_root_turn_id: Optional[UUID] = None


async def _resolve_ask_target(
    db: AsyncSession, paper_id: UUID, payload: AskPayload
) -> tuple[dict, Optional[UUID]]:
    """Shared /ask pre-checks: 404, sub-thread depth cap, chunk resolution."""
    doc = await doc_service.get_document(db, paper_id)
    if not doc:
        raise DocumentNotFound(str(paper_id))

    # Sub-thread depth cap: a sub-thread rooted at R has depth = chain_len(R) + 1.
    # Block any /ask whose target sub-thread would exceed MAX_SUB_THREAD_DEPTH.
    # This is defense-in-depth: the UI also hides the "Thread →" affordance at L3.
    if payload.thread_root_turn_id is not None:
        root_chain = await conv_repo.compute_turn_depth(db, payload.thread_root_turn_id)
        sub_depth = root_chain + 1
        if sub_depth > MAX_SUB_THREAD_DEPTH:
            raise HTTPException(
                status_code=400,
                detail=(
                    f"Sub-thread nesting limit reached (max {MAX_SUB_THREAD_DEPTH} levels). "
                    "Reply in the current thread or step back to a shallower level."
                ),
            )

    # Resolve current_chunk_id from sequence_order if provided
    current_chunk_id = None
    if payload.current_sequence_order is not None:
        chunk = await chunk_repo.get_chunk_by_sequence(
            db, paper_id, payload.current_sequence_order
        )
        if chunk:
            current_chunk_id = chunk["id"]

    return doc, current_chunk_id


@router.post("/{paper_id}/ask")
async def ask_paper(
    paper_id: UUID,
    payload: AskPayload,
    db: AsyncSession = Depends(get_db),
    _limiter: None = Depends(get_ask_limiter),
):
    """Route a prompt to Local, Global, or External context and return an answer."""
    doc, current_chunk_id = await _resolve_ask_target(db, paper_id, payload)

    result = await handle_ask(
        db,
        prompt=payload.query,
        document_id=paper_id,
        current_chunk_id=current_chunk_id,
        conversation_id=payload.conversation_id,
        visible_sequence_orders=payload.visible_sequence_orders,
        focused_element=payload.focused_element,
        user_images_b64=payload.images_b64,
        parent_turn_id=payload.parent_turn_id,
        thread_root_turn_id=payload.thread_root_turn_id,
        document=doc,
    )

    return {
        "answer": result.answer,
        "context_type": result.context_type,
        "router_reason": result.router_reason,
        "citations": [c.model_dump(mode="json") for c in result.citations],
        "model": result.model,
        "conversation_id": str(result.conversation_id) if result.conversation_id else None,
    }


@router.post("/{paper_id}/ask/stream")
async def ask_paper_stream(
    paper_id: UUID,
    payload: AskPayload,
    db: AsyncSession = Depends(get_db),
):
    """Streaming /ask: same pipeline, answer delivered as Server-Sent Events.

    Event types (one JSON object per `data:` line): token, status, replace,
    done (full AskResponse payload), error. The concurrency semaphore is
    acquired inside the generator — a Depends-based limiter would release
    before the stream body even starts running.
    """
    doc, current_chunk_id = await _resolve_ask_target(db, paper_id, payload)

    def sse(event: dict) -> str:
        return f"data: {json.dumps(event)}\n\n"

    async def event_stream():
        async with get_ask_semaphore():
            try:
                async for event in handle_ask_stream(
                    prompt=payload.query,
                    document_id=paper_id,
                    current_chunk_id=current_chunk_id,
                    conversation_id=payload.conversation_id,
                    visible_sequence_orders=payload.visible_sequence_orders,
                    focused_element=payload.focused_element,
                    user_images_b64=payload.images_b64,
                    parent_turn_id=payload.parent_turn_id,
                    thread_root_turn_id=payload.thread_root_turn_id,
                    document=doc,
                ):
                    yield sse(event)
            except NoLLMConfigured as e:
                # The message already carries full instructions — no prefix.
                yield sse({"type": "error", "detail": str(e.model)})
            except ModelUnavailable as e:
                yield sse({"type": "error", "detail": f"Model unavailable: {e.model}"})
            except Exception:
                logger.exception("ask_stream failed")
                yield sse({"type": "error", "detail": "Answer generation failed. Check the server logs."})

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            # Tell nginx-style proxies not to buffer the stream.
            "X-Accel-Buffering": "no",
        },
    )


def _serialize_turn(t: dict) -> dict:
    return {
        "id": str(t["id"]),
        "conversation_id": str(t["conversation_id"]) if t.get("conversation_id") else None,
        "role": t["role"],
        "content": t["content"],
        "context_type": t.get("context_type"),
        "citations": t.get("citations"),
        "created_at": t["created_at"].isoformat() if t.get("created_at") else None,
        # Sub-thread support (None for main linear chat turns)
        "parent_turn_id": str(t["parent_turn_id"]) if t.get("parent_turn_id") else None,
    }


@router.get("/{paper_id}/chat")
async def get_paper_chat(
    paper_id: UUID,
    conversation_id: Optional[UUID] = Query(default=None),
    # NEW: when provided, return the full subtree for this sub-thread root
    # (includes the original branching user turn + its first AI reply + all descendants).
    thread_root_turn_id: Optional[UUID] = Query(default=None),
    db: AsyncSession = Depends(get_db),
):
    """Return saved chat turns for a paper, oldest first.

    - If ``thread_root_turn_id`` is provided → return the full sub-thread subtree
      (special loader that includes the original first AI reply even if its
      parent_turn_id is still NULL).
    - Else if ``conversation_id`` is provided → the main linear chat for that conv
      (turns with parent_turn_id IS NULL), with assistant turns that start a
      sub-thread augmented with `thread_root_turn_id` so the frontend knows
      exactly which user turn to use as the root when the user clicks "Thread →".
    - Otherwise legacy behaviour (all turns for the paper).
    """
    doc = await doc_service.get_document(db, paper_id)
    if not doc:
        raise DocumentNotFound(str(paper_id))

    if thread_root_turn_id is not None:
        turns = await conv_repo.get_thread_subtree(db, thread_root_turn_id)
        # Depth of this sub-thread = chain length of its root turn + 1.
        root_chain = await conv_repo.compute_turn_depth(db, thread_root_turn_id)
        sub_depth = root_chain + 1
        # Pair user→assistant inside the sub-thread so each pair becomes a
        # potential deeper sub-thread root. Suppress pairing for the root
        # itself (clicking it would re-open the same sub-thread).
        serialized = []
        prev_user_id = None
        root_id_str = str(thread_root_turn_id)
        for t in turns:
            st = _serialize_turn(t)
            if t.get("role") == "user":
                prev_user_id = t["id"]
            elif t.get("role") == "assistant" and prev_user_id is not None:
                pid_str = str(prev_user_id)
                # Skip the (root user, first AI reply) pair — same sub-thread.
                if pid_str != root_id_str:
                    st["thread_root_turn_id"] = pid_str
            serialized.append(st)
        return {
            "turns": serialized,
            "is_sub_thread": True,
            "depth": sub_depth,
            "max_depth": MAX_SUB_THREAD_DEPTH,
        }

    if conversation_id is not None:
        # Main chat view must exclude sub-thread turns (parent_turn_id IS NOT NULL),
        # otherwise replies sent inside a tangent leak into the primary linear chat.
        turns = await conv_repo.get_main_chat(db, conversation_id)
    else:
        turns = await conv_repo.list_turns_by_document(db, paper_id)

    # Post-process for the main chat view: attach the preceding user turn id
    # to every assistant turn. The frontend uses this as `thread_root_turn_id`
    # when the user clicks "Thread →". It can separately call has_children / count
    # (or we can extend the response later) to decide whether to actually show
    # the affordance. This keeps the endpoint simple and fast.
    serialized = []
    prev_user_id = None
    for t in turns:
        st = _serialize_turn(t)
        if t.get("role") == "user":
            prev_user_id = t["id"]
        elif t.get("role") == "assistant" and prev_user_id is not None:
            st["thread_root_turn_id"] = str(prev_user_id)
        serialized.append(st)

    return {
        "turns": serialized,
        "is_sub_thread": False,
        "depth": 0,
        "max_depth": MAX_SUB_THREAD_DEPTH,
    }


@router.get("/{paper_id}/conversations")
async def list_paper_conversations(
    paper_id: UUID,
    db: AsyncSession = Depends(get_db),
):
    """List every distinct conversation thread for a paper, most-recent first."""
    doc = await doc_service.get_document(db, paper_id)
    if not doc:
        raise DocumentNotFound(str(paper_id))

    rows = await conv_repo.list_conversations_by_document(db, paper_id)
    return {
        "conversations": [
            {
                "conversation_id": str(r["conversation_id"]) if r.get("conversation_id") else None,
                "turn_count": int(r["turn_count"]),
                "started_at": r["started_at"].isoformat() if r.get("started_at") else None,
                "last_at": r["last_at"].isoformat() if r.get("last_at") else None,
                "first_user_message": r.get("first_user_message"),
            }
            for r in rows
            if r.get("conversation_id") is not None
        ],
    }