Files
nextcraft/apps/ai-service/ai_service/api/chat.py
T
CIAgent 88a1dab810 docs(milestone): complete v0.2-ai-tutor-architecture
---ci---
phase: 7
milestone: v0.2
status: complete
requirements:
  covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012]
  partial: []
---/ci---

Milestone v0.2 (ai-tutor-architecture) merged to main.

Escalation record (audit remediation, durable): P1 executor
delegation failed twice (empty subagent results, zero files
created); auto-resolved at full autonomy to inline execution with
identical plan fidelity (commit 3271373, reflog-only after phase
branch squash-delete).
2026-09-11 17:34:50 +00:00

129 lines
4.9 KiB
Python

"""POST /v1/chat/stream — SSE chat with the D-016 envelope + agent routing.
Envelope: meta event first (flushed before first token), then raw content
deltas, then done; error event before [DONE] on mid-stream failure.
Pre-first-byte provider failures surface as in-band `provider_unavailable`
error events (SSE 200 headers are already committed once meta flushes).
Agent routing (A-007): the request names its agent; unknown agents are
rejected with 422. No autonomous routing in v0.2.
"""
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.registry import AgentRegistry, UnknownAgentError
from ..agents.session import SessionStore
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..llm.base import LLMProvider
from ..llm.types import Message
from .deps import get_agent_registry, get_provider, get_session_store, get_settings
router = APIRouter(prefix="/v1")
class ChatStreamRequest(BaseModel):
agent: str = Field(min_length=1)
session_id: str = Field(min_length=1)
learner_id: str | None = None
messages: list[Message] = Field(min_length=1)
@router.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequest,
registry: AgentRegistry = Depends(get_agent_registry),
sessions: SessionStore = Depends(get_session_store),
settings: Settings = Depends(get_settings),
provider: LLMProvider = Depends(get_provider),
) -> EventSourceResponse:
# Route to the named agent (A-007); unknown → 422 before any streaming.
try:
agent = registry.get(provider, settings, body.agent)
except UnknownAgentError as exc:
raise HTTPException(
status_code=422, detail=str(exc)
) from None
learner_context = get_learner_context(body.learner_id)
# Agent-scoped session (A-007/G-4): persisted turn history, windowed replay.
session = await sessions.get(body.session_id)
if session is None:
session = await sessions.create(
body.session_id, agent=body.agent, learner_id=body.learner_id or "learner-001"
)
# The new user turn is the last message of the request.
user_turn = body.messages[-1]
history = await sessions.history_window(body.session_id)
# Retry dedupe (P1 from final review): a client retry resends the same
# turn after a provider failure — don't double-append it to history.
last_stored = history[-1] if history else None
is_retry = (
last_stored is not None
and last_stored.role == "user"
and last_stored.content == user_turn.content
)
if not is_retry:
await sessions.append(body.session_id, user_turn)
else:
# On retry the history replay should exclude the stored duplicate.
history = history[:-1]
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": body.agent,
"session_id": body.session_id,
"model": settings.model,
})}
first_byte = True
reply_parts: list[str] = []
try:
async for token in agent.stream_reply(
history=history,
user_input=user_turn.content,
learner_context=learner_context,
):
first_byte = False
reply_parts.append(token)
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
full_reply = "".join(reply_parts)
if full_reply:
await sessions.append(
body.session_id, Message(role="assistant", content=full_reply)
)
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
yield {"event": "message", "data": "[DONE]"}
except Exception as exc: # CancelledError is BaseException — passes through
message = str(exc)
if first_byte:
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_unavailable", "message": message
})}
else:
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_error", "message": message
})}
# [DONE] is yielded from the except branch, NEVER from finally:
# a yield inside finally would re-raise after GeneratorExit when the
# client disconnects ("async generator ignored GeneratorExit").
yield {"event": "message", "data": "[DONE]"}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)