import json import logging import os import random from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest from services.llm import LLMError, send_message, stream_message from services.memory import ( get_history, add_message, clear_history, get_or_create_session, get_session, update_session_title, get_message_count, get_last_assistant_message_id, update_message_image, update_session_facts, update_session_status_quo, update_session_genre, update_session_plot_arc, get_quests, seed_quests_from_arc, narrator_message_content, parse_narrator_message, add_action_resolution, get_message, update_message_content, delete_messages_after, delete_message, delete_message_and_following, update_message_choices, clear_choices_for_session, upsert_static_system_message, ) from services.context_budget import compute_payload_usage, context_warning_line from services.rpg_state import ( apply_narrator_post, parse_scene_json, parse_stats_json, scene_prompt_block, affinity_prompt_block, stats_prompt_block, format_narrator_outcome_for_llm, format_user_message_for_llm, ) from services.personas import get_persona from services.chat_prompt import get_system_prompt, DEFAULT_PROMPT from services.session_identity import resolve_session_persona from services.sd_prompt import generate_sd_prompt, strip_image_prompt_tag, extract_image_prompt_tag from services.rp_sanitize import RP_OUTPUT_REMINDER, strip_ooc_from_reply from services.sd_images import run_sd_for_message from services.character_card import get_character from services import sdbackend as sd_service from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene from services.rpg_context import format_narrator_context from services.rpg_plot import ( generate_plot_arc, process_arc_beats, advance_phase, replenish_arc_beats, reconcile_plot_arc, reconcile_plot_arc, choices_from_beat, choices_from_narrator, ) from services.rpg_narrator import narrator_pre, narrator_post from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening logger = logging.getLogger(__name__) router = APIRouter(prefix="/chat", tags=["chat"]) SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes") DEFAULT_RPG_SETTINGS = { "dice": True, "narrator": True, "quests": True, "affinity": True, "choices": True, "stats": False, } def get_rpg_settings(session: dict) -> dict: try: return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")} except Exception: return DEFAULT_RPG_SETTINGS def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str = "") -> str: runtime_suffix = "" if facts_block: runtime_suffix += "\n\n" + facts_block try: arc = json.loads(session.get("plot_arc_json") or "{}") except Exception: arc = {} if arc: runtime_suffix += "\n\n--- PlotArc ---\n" + json.dumps( {k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False ) + "\n---" status_quo = (session.get("status_quo") or "").strip() if status_quo: from services.rp_sanitize import status_quo_prompt_block runtime_suffix += status_quo_prompt_block(status_quo) scene = parse_scene_json(session.get("scene_json")) block = scene_prompt_block(scene) if block: runtime_suffix += block if rpg_settings.get("affinity", True): runtime_suffix += affinity_prompt_block(int(session.get("affinity") or 0)) if rpg_settings.get("stats", False): stats = parse_stats_json(session.get("narrative_stats_json")) runtime_suffix += stats_prompt_block(stats) return runtime_suffix def messages_for_llm(history: list, llm_system_content: str) -> list[dict]: """Build LLM payload: one system message (static + runtime), no duplicate system rows.""" out: list[dict] = [] system_used = False for m in history: if m["role"] == "system": if not system_used: out.append({"role": "system", "content": llm_system_content}) system_used = True elif m["role"] == "narrator": data = parse_narrator_message(m.get("content") or "") if data: out.append({"role": "user", "content": format_narrator_outcome_for_llm(data)}) elif m["role"] == "user": has_res = bool(m.get("action_resolution")) out.append({ "role": "user", "content": format_user_message_for_llm( m["content"], has_dice_resolution=has_res ), }) else: out.append({"role": m["role"], "content": m["content"]}) if not system_used: out.insert(0, {"role": "system", "content": llm_system_content}) return out @router.get("/history/{session_id}") async def get_chat_history(session_id: str): return await get_history(session_id) @router.get("/system/{session_id}") async def get_system_blob(session_id: str): history = await get_history(session_id) session = await get_session(session_id) if session and session.get("rpg_enabled"): persona_id_pre = (session.get("persona_id") or "default") persona_pre = await get_persona(persona_id_pre) or {} await reconcile_plot_arc( session_id, persona_name=persona_pre.get("name", persona_id_pre), recent_context=(session.get("status_quo") or "")[:2000], genre=session.get("genre") or "adventure", ) session = await get_session(session_id) or session persona_id = (session.get("persona_id") if session else None) or "default" persona = await get_persona(persona_id) or {} system_msg = next((m for m in history if m.get("role") == "system"), None) stored = system_msg.get("content") if system_msg else "" live_static = await get_system_prompt(persona_id, history, "") system_prompt = live_static if live_static else stored quests = await get_quests(session_id) rpg_settings = get_rpg_settings(session) if session else DEFAULT_RPG_SETTINGS facts_block = facts_to_prompt(session.get("facts_json", "[]")) if session else "" runtime_suffix = "" if session and session.get("rpg_enabled"): runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) llm_system = system_prompt + runtime_suffix context_usage = compute_payload_usage(history, llm_system) return { "persona_id": persona_id, "persona_name": persona.get("name", persona_id), "system_prompt": system_prompt, "status_quo": session.get("status_quo") if session else "", "global_plot": session.get("global_plot") if session else "", "facts_json": session.get("facts_json") if session else "[]", "plot_arc_json": session.get("plot_arc_json") if session else "{}", "outfit_json": session.get("outfit_json") if session else "[]", "scene_json": session.get("scene_json") if session else "{}", "narrative_stats_json": session.get("narrative_stats_json") if session else "{}", "affinity": session.get("affinity", 0) if session else 0, "genre": session.get("genre", "") if session else "", "rpg_settings_json": session.get("rpg_settings_json") if session else "{}", "rpg_enabled": bool(session.get("rpg_enabled")) if session else False, "quests": quests, "context_usage": context_usage, } @router.post("/init") async def init_chat(request: ChatRequest): await get_or_create_session( request.session_id, request.persona_id or "default", ) persona_id = await resolve_session_persona( request.session_id, request.persona_id, create_persona=request.persona_id, ) history = await get_history(request.session_id) if history: return {"first_mes": None} system_prompt = await get_system_prompt(persona_id, [], "") await upsert_static_system_message(request.session_id, system_prompt, []) first_mes = None if request.first_mes_override and request.first_mes_override.strip(): first_mes = request.first_mes_override.strip() await add_message(request.session_id, "assistant", first_mes) else: persona = await get_persona(persona_id) if persona and persona.get("first_mes"): first_mes = persona["first_mes"] await add_message(request.session_id, "assistant", first_mes) elif persona_id.startswith("card_"): card = await get_character(persona_id[5:]) if card and card.get("first_mes"): first_mes = card["first_mes"] await add_message(request.session_id, "assistant", first_mes) return {"first_mes": first_mes} class RpgBootstrapRequest(BaseModel): session_id: str persona_id: str = "default" genre: str = "adventure" class OpeningProcessRequest(BaseModel): session_id: str persona_id: str = "default" rpg: bool = False @router.post("/opening/process") async def opening_process(req: OpeningProcessRequest): await get_or_create_session(req.session_id, req.persona_id) persona_id = await resolve_session_persona(req.session_id, req.persona_id) try: return await process_opening(req.session_id, persona_id, rpg=req.rpg) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.post("/rpg/bootstrap") async def rpg_bootstrap(req: RpgBootstrapRequest): await get_or_create_session(req.session_id, req.persona_id) persona_id = await resolve_session_persona(req.session_id, req.persona_id) await update_session_genre(req.session_id, req.genre) persona = await get_persona(persona_id) or {} greeting = await resolve_greeting(req.session_id, persona) arc = await ensure_plot_arc_and_quests(req.session_id, persona, greeting, req.genre) session = await get_session(req.session_id) or {} rpg_settings = get_rpg_settings(session) if rpg_settings.get("narrator", True) and greeting: arc_json = json.dumps(arc, ensure_ascii=False) if arc else "" facts_block = facts_to_prompt(session.get("facts_json", "[]")) post = await narrator_post( persona.get("name", persona_id), f"assistant: {greeting}", arc_json, facts_block, is_opening=True, ) await apply_narrator_post(req.session_id, post, rpg_settings, session) quests = await get_quests(req.session_id) updated = await get_session(req.session_id) or {} return { "plot_arc": arc, "quests": quests, "affinity": updated.get("affinity", 0), "scene_json": updated.get("scene_json", "{}"), "narrative_stats_json": updated.get("narrative_stats_json", "{}"), } @router.post("/stream") async def chat_stream(request: ChatRequest): await get_or_create_session(request.session_id, request.persona_id) persona_id = await resolve_session_persona( request.session_id, request.persona_id, create_persona=request.persona_id, ) history = await get_history(request.session_id) session = await get_session(request.session_id) static_prompt = await get_system_prompt(persona_id, history, request.message) runtime_suffix = "" arc = {} roll = None outcome = None resolution_text = "" narrator_msg = None # shown as narrator bubble before assistant reply rpg_settings = {} facts_block = "" narrator_extra = "" pre = {} directives: list = [] pre_ok = False if session and session.get("rpg_enabled"): rpg_settings = get_rpg_settings(session) facts_block = facts_to_prompt(session.get("facts_json", "[]")) try: arc = json.loads(session.get("plot_arc_json") or "{}") except Exception: arc = {} quests_list = await get_quests(request.session_id) narr_ctx = format_narrator_context( arc, quests_list, session.get("status_quo") or "" ) if rpg_settings.get("narrator", True): persona = await get_persona(persona_id) or {} recent_txt = "\n".join( f"{m['role']}: {m['content']}" for m in history[-8:] if m.get("role") in ("user", "assistant") ) # Phase 1: ask narrator if check is needed (no roll yet) pre = await narrator_pre( persona.get("name", persona_id), recent_txt, json.dumps(arc, ensure_ascii=False) if arc else "", facts_block, request.message, extra_context=narr_ctx, ) pre_ok = bool(pre.get("_ok")) needs_check = pre.get("needs_check", False) and rpg_settings.get("dice", True) if needs_check: # Phase 2: roll and get resolution roll = random.randint(1, 20) if roll == 1: outcome = "critical failure" elif roll <= 8: outcome = "failure" elif roll >= 20: outcome = "critical success" else: outcome = "success" pre2 = await narrator_pre( persona.get("name", persona_id), recent_txt, json.dumps(arc, ensure_ascii=False) if arc else "", facts_block, request.message, roll=roll, outcome=outcome, extra_context=narr_ctx, ) resolution_text = (pre2.get("resolution_text") or "").strip() directives = pre2.get("directives") or [] pre_sq = (pre2.get("status_quo_update") or "").strip() else: directives = pre.get("directives") or [] pre_sq = (pre.get("status_quo_update") or "").strip() if directives: narrator_extra += ( "\n\n--- Narrator directives ---\n" + "\n".join(f"- {d}" for d in directives) + "\n---" ) if pre_sq: await update_session_status_quo(request.session_id, pre_sq) session["status_quo"] = pre_sq pre_for_scene = pre2 if needs_check else pre scene_up = pre_for_scene.get("scene_update") if isinstance(scene_up, dict) and scene_up: from services.rpg_state import merge_scene from services.memory import update_session_scene merged = merge_scene( parse_scene_json(session.get("scene_json")), scene_up ) scene_str = json.dumps(merged, ensure_ascii=False) await update_session_scene(request.session_id, scene_str) session["scene_json"] = scene_str if resolution_text: narrator_msg = { "roll": roll, "outcome": outcome, "text": resolution_text, "original_intent": request.message, } if roll is not None and resolution_text: narrator_extra += ( f"\n\n--- Mechanics (this turn) ---\n" f"Roll d20={roll}. Outcome: {outcome}.\n" f"Narrator resolution: {resolution_text}\n" "The character's next reply MUST match the narrator ruling in the message history " "(immediately after the player's intent). Do NOT re-enact the attempt as full success on failure.\n" "---" ) runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra llm_system = static_prompt + runtime_suffix if persona_id != "default" or (session and session.get("rpg_enabled")): llm_system += RP_OUTPUT_REMINDER user_message_content = request.message if request.is_narrator_choice: user_message_content = f"[Player chose: {request.message}]" await upsert_static_system_message(request.session_id, static_prompt, history) user_msg_id = None if not request.skip_user_add: await clear_choices_for_session(request.session_id) user_msg_id = await add_message(request.session_id, "user", user_message_content) if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id: await add_action_resolution( request.session_id, intent_text=request.message, roll=narrator_msg["roll"], outcome=narrator_msg["outcome"], resolution_text=narrator_msg["text"], message_id=user_msg_id, ) narrator_msg["user_message_id"] = user_msg_id if narrator_msg and (narrator_msg.get("text") or "").strip(): await add_message( request.session_id, "narrator", narrator_message_content(narrator_msg), ) messages = await get_history(request.session_id) usage = compute_payload_usage(messages, llm_system) warn = context_warning_line(usage.get("percent", 0)) if warn: llm_system += warn llm_messages = messages_for_llm(messages, llm_system) full_reply = [] async def generate(): nonlocal arc if narrator_msg: yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n" try: async for chunk in stream_message(llm_messages): full_reply.append(chunk) yield f"data: {json.dumps({'chunk': chunk})}\n\n" except Exception as e: logger.error("stream_message failed: %s", e) yield f"data: {json.dumps({'error': str(e)})}\n\n" return complete = "".join(full_reply) raw_display = strip_image_prompt_tag(complete) display_text = strip_ooc_from_reply(raw_display) if (display_text or raw_display).strip(): await add_message(request.session_id, "assistant", display_text or raw_display) choices = [] debug_blocks = [] quests_updated = [] narrator_meta = {} if session and session.get("rpg_enabled"): try: if not arc: persona = await get_persona(persona_id) or {} arc = await generate_plot_arc( persona.get("name", persona_id), persona.get("description", ""), persona.get("scenario", ""), persona.get("first_mes", ""), facts_block=facts_to_prompt(session.get("facts_json", "[]")), genre=session.get("genre") or "adventure", ) if arc: await update_session_plot_arc( request.session_id, json.dumps(arc, ensure_ascii=False) ) debug_blocks.append({ "type": "plot_arc", "text": json.dumps(arc, ensure_ascii=False, indent=2), }) if rpg_settings.get("quests", True): await seed_quests_from_arc(request.session_id, arc) quests_list = await get_quests(request.session_id) if arc: beat_ctx = "\n".join( f"{m['role']}: {m['content']}" for m in (await get_history(request.session_id))[-6:] if m.get("role") in ("user", "assistant") ) arc, beats, pruned, beat_mode = await process_arc_beats( arc, quests_list, request.message, recent_context=beat_ctx, last_dice_outcome=outcome if roll is not None else None, ) if pruned or beats: await update_session_plot_arc( request.session_id, json.dumps(arc, ensure_ascii=False) ) if pruned: debug_blocks.append({ "type": "plot_arc_prune", "text": f"Removed {len(pruned)} beat(s) already completed as quests", }) if beats: inj = beats[0].get("injection", "") if inj: debug_blocks.append({"type": "narrator_injection", "text": inj}) if rpg_settings.get("choices", True): choices += choices_from_beat(beats[0]) if beat_mode in ("after_dice", "llm", "trigger", "stuck_recovery"): debug_blocks.append({ "type": "plot_arc", "text": ( f"Beat fired ({beat_mode}): " f"«{beats[0].get('title', '')}»" ), }) if advance_phase(arc): await update_session_plot_arc( request.session_id, json.dumps(arc, ensure_ascii=False) ) debug_blocks.append({"type": "phase_advance", "text": arc["phase"]}) if pruned and not arc.get("beats"): narrator_meta["arc_pruned"] = len(pruned) if beat_mode: narrator_meta["beat_mode"] = beat_mode ctx = [ m for m in (await get_history(request.session_id)) if m["role"] in ("user", "assistant") ][-10:] new_facts = await extract_facts( ctx, rp_day_hint=rp_day_from_scene(session.get("scene_json")), existing_json=session.get("facts_json", "[]"), ) if new_facts: merged = await merge_facts_persist( session.get("facts_json", "[]"), new_facts, rp_day_default=rp_day_from_scene(session.get("scene_json")), scene_context=json.dumps( parse_scene_json(session.get("scene_json")), ensure_ascii=False, ), status_quo=session.get("status_quo") or "", ) await update_session_facts(request.session_id, merged) session["facts_json"] = merged persona = await get_persona(persona_id) or {} ctx_txt = "\n".join( f"{m['role']}: {m['content']}" for m in ctx[-8:] if m.get("role") in ("user", "assistant") ) narr_ctx_post = format_narrator_context( arc, await get_quests(request.session_id), session.get("status_quo") or "" ) post = await narrator_post( persona.get("name", persona_id), ctx_txt, json.dumps(arc, ensure_ascii=False) if arc else "", facts_to_prompt(session.get("facts_json", "[]")), extra_context=narr_ctx_post, ) sq = (post.get("status_quo_update") or "").strip() if sq: debug_blocks.append({"type": "status_quo", "text": sq}) if rpg_settings.get("choices", True): choices += choices_from_narrator(post.get("choices") or []) applied = await apply_narrator_post( request.session_id, post, rpg_settings, session ) narrator_meta = { "pre_ok": pre_ok, "post_ok": bool(post.get("_ok")), "choices_count": len(choices), "directives_count": len(directives), "dice": roll is not None, **applied, } if not arc.get("beats"): persona = await get_persona(persona_id) or {} arc = await replenish_arc_beats( arc, persona.get("name", persona_id), ctx_txt, await get_quests(request.session_id), session.get("genre") or "adventure", ) if arc.get("beats"): await update_session_plot_arc( request.session_id, json.dumps(arc, ensure_ascii=False) ) debug_blocks.append({ "type": "plot_arc", "text": f"Added {len(arc.get('beats', []))} new plot beats", }) narrator_meta["beats_replenished"] = len(arc.get("beats", [])) if rpg_settings.get("quests", True): await seed_quests_from_arc(request.session_id, arc) outfit_update = post.get("outfit_update") if isinstance(outfit_update, list) and outfit_update: from services.outfit_tags import outfit_list_to_json session["outfit_json"] = outfit_list_to_json(outfit_update) quests_updated = await get_quests(request.session_id) except LLMError as e: logger.warning("RPG post-process skipped after reply: %s", e) except Exception as e: logger.exception("RPG post-process failed after reply: %s", e) count = await get_message_count(request.session_id) if count == 2 and not request.skip_user_add: persona = await get_persona(persona_id) or {} preview = request.message[:40] + ("…" if len(request.message) > 40 else "") if (session or {}).get("title", "Новый чат") in ("", "Новый чат"): await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}") updated_session = await get_session(request.session_id) or session hist = await get_history(request.session_id) bundle = await generate_sd_prompt( hist, persona_id, outfit_json=updated_session.get("outfit_json", "[]") if updated_session else "[]", scene_json=updated_session.get("scene_json", "{}") if updated_session else "{}", ) prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(complete) msg_id = await get_last_assistant_message_id(request.session_id) if msg_id and choices: await update_message_choices( msg_id, json.dumps(choices, ensure_ascii=False) ) sd_out: dict = {} if bundle: yield f"data: {json.dumps({ 'image_generating': True, 'image_prompt': bundle.tag_full, 'image_prompt_alt': bundle.desc_full, })}\n\n" sd_out = await run_sd_for_message(bundle, msg_id) elif prompt_str and SD_AUTO_GENERATE: yield f"data: {json.dumps({'image_generating': True, 'image_prompt': prompt_str})}\n\n" rel, err = await sd_service.generate_from_full_prompt(prompt_str) if rel: sd_out["image_path"] = f"/static/{rel}" if msg_id: await update_message_image(msg_id, rel) else: sd_out["image_error"] = err sd_out["image_prompt"] = prompt_str affinity = updated_session.get("affinity", 0) if updated_session else 0 done_payload = { "done": True, "assistant_message_id": msg_id, "assistant_content": display_text or raw_display, "image_prompt": sd_out.get("image_prompt") or prompt_str, "image_prompt_alt": sd_out.get("image_prompt_alt"), "image_path": sd_out.get("image_path"), "image_path_alt": sd_out.get("image_path_alt"), "image_error": sd_out.get("image_error"), "image_error_alt": sd_out.get("image_error_alt"), "choices": choices, "debug": debug_blocks, "affinity": affinity, "quests": quests_updated, "narrator_meta": narrator_meta, } if rpg_settings.get("stats") and updated_session: done_payload["narrative_stats"] = parse_stats_json( updated_session.get("narrative_stats_json") ) yield f"data: {json.dumps(done_payload)}\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.post("/", response_model=ChatResponse) async def chat(request: ChatRequest): await get_or_create_session(request.session_id, request.persona_id) persona_id = await resolve_session_persona( request.session_id, request.persona_id, create_persona=request.persona_id, ) history = await get_history(request.session_id) static_prompt = await get_system_prompt(persona_id, history, request.message) await upsert_static_system_message(request.session_id, static_prompt, history) await add_message(request.session_id, "user", request.message) messages = await get_history(request.session_id) session = await get_session(request.session_id) llm_system = static_prompt if session and session.get("rpg_enabled"): rpg_settings = get_rpg_settings(session) facts_block = facts_to_prompt(session.get("facts_json", "[]")) llm_system += build_rpg_runtime_suffix(session, rpg_settings, facts_block) if persona_id != "default" or (session and session.get("rpg_enabled")): llm_system += RP_OUTPUT_REMINDER llm_messages = messages_for_llm(messages, llm_system) reply = await send_message(llm_messages) display = strip_ooc_from_reply(strip_image_prompt_tag(reply)) bundle = await generate_sd_prompt( messages, persona_id, outfit_json=session.get("outfit_json", "[]") if session else "[]", scene_json=session.get("scene_json", "{}") if session else "{}", ) prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(reply) await add_message(request.session_id, "assistant", display, image_prompt=prompt_str) return ChatResponse( reply=display, session_id=request.session_id, image_prompt=prompt_str, ) @router.delete("/messages/{message_id}") async def remove_message(message_id: int): msg = await get_message(message_id) if not msg: raise HTTPException(status_code=404, detail="Сообщение не найдено") await delete_message_and_following(msg["session_id"], message_id) return {"status": "deleted", "message_id": message_id} @router.patch("/messages/{message_id}") async def edit_message(message_id: int, req: MessageEditRequest): msg = await get_message(message_id) if not msg: raise HTTPException(status_code=404, detail="Сообщение не найдено") await update_message_content(message_id, req.content) if req.truncate_after: await delete_messages_after(msg["session_id"], message_id) return {"status": "updated", "message_id": message_id} @router.post("/regenerate") async def regenerate_chat(req: RegenerateRequest): msg_id = req.message_id or await get_last_assistant_message_id(req.session_id) if not msg_id: raise HTTPException(status_code=400, detail="Нет сообщения для перегенерации") msg = await get_message(msg_id) if not msg or msg.get("role") != "assistant": raise HTTPException(status_code=400, detail="Неверное сообщение") await delete_message(msg_id) history = await get_history(req.session_id) last_user = next((m for m in reversed(history) if m["role"] == "user"), None) if not last_user: raise HTTPException(status_code=400, detail="Нет сообщения пользователя") user_text = last_user["content"] if user_text.startswith("[Player chose: ") and user_text.endswith("]"): user_text = user_text[15:-1] stream_req = ChatRequest( message=user_text, session_id=req.session_id, skip_user_add=True, ) return await chat_stream(stream_req) @router.delete("/{session_id}") async def clear_chat(session_id: str): await clear_history(session_id) return {"status": "cleared", "session_id": session_id}