import json from fastapi import APIRouter, HTTPException from services.memory import ( get_all_sessions, get_session, get_or_create_session, get_history, delete_session, update_session_title, update_session_persona, get_message_count, update_session_rpg, update_session_facts, update_session_global_plot, update_session_status_quo, update_session_genre, update_session_rpg_settings, get_quests, update_quest_by_id, set_session_affinity, update_session_narrative_stats, update_session_outfit, update_session_scene, update_session_plot_arc, get_last_message_preview, fork_session, ) from models.schemas import ( ForkSessionRequest, RebindPersonaRequest, QuestStatusPatch, RpgStateDebugPatch, SessionContextPatch, ) from services.rpg_plot import reconcile_plot_arc from services.rpg_state import parse_stats_json from services.chat_prompt import get_system_prompt from services.memory import rebind_session_persona from services.personas import get_persona router = APIRouter(prefix="/sessions", tags=["sessions"]) @router.get("/") async def list_sessions(): sessions = await get_all_sessions() result = [] for s in sessions: count = await get_message_count(s["session_id"]) preview = await get_last_message_preview(s["session_id"]) result.append({**s, "message_count": count, "last_message_preview": preview}) return result @router.get("/{session_id}/quests") async def list_quests(session_id: str): session = await get_session(session_id) if session and session.get("rpg_enabled"): persona = await get_persona(session.get("persona_id") or "default") or {} await reconcile_plot_arc( session_id, persona_name=persona.get("name", session.get("persona_id") or "Character"), recent_context=(session.get("status_quo") or "")[:2000], genre=session.get("genre") or "adventure", ) return await get_quests(session_id) @router.patch("/{session_id}/context") async def patch_session_context(session_id: str, body: SessionContextPatch): """Live-edit session context (outfit, scene, plot, facts, status quo).""" from services.outfit_tags import parse_and_normalize_outfit_json session = await get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Сессия не найдена") if body.status_quo is not None: await update_session_status_quo(session_id, body.status_quo) if body.global_plot is not None: await update_session_global_plot(session_id, body.global_plot) if body.outfit_json is not None: try: normalized = parse_and_normalize_outfit_json(body.outfit_json) json.loads(normalized) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"outfit_json: {e}") from e await update_session_outfit(session_id, normalized) if body.scene_json is not None: try: json.loads(body.scene_json or "{}") except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"scene_json: {e}") from e await update_session_scene(session_id, body.scene_json) if body.facts_json is not None: from services.rpg_facts import ( parse_facts_list, facts_list_to_json, dedupe_facts_fuzzy, compress_facts, FACTS_DEDUP_THRESHOLD, FACTS_COMPRESS_TARGET, ) try: facts = dedupe_facts_fuzzy(parse_facts_list(body.facts_json)) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"facts_json: {e}") from e if len(facts) > FACTS_DEDUP_THRESHOLD: facts = await compress_facts( facts, status_quo=(session.get("status_quo") or ""), scene_context=session.get("scene_json") or "{}", target=FACTS_COMPRESS_TARGET, ) facts = dedupe_facts_fuzzy(facts) normalized = facts_list_to_json(facts) await update_session_facts(session_id, normalized) if body.plot_arc_json is not None: try: json.loads(body.plot_arc_json or "{}") except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"plot_arc_json: {e}") from e await update_session_plot_arc(session_id, body.plot_arc_json) if body.affinity is not None: await set_session_affinity(session_id, body.affinity) stats_changed = any( getattr(body, k) is not None for k in ("lust", "stamina", "tension") ) if stats_changed: stats = parse_stats_json(session.get("narrative_stats_json")) for key in ("lust", "stamina", "tension"): val = getattr(body, key, None) if val is not None: stats[key] = max(0, min(10, int(val))) await update_session_narrative_stats( session_id, json.dumps(stats, ensure_ascii=False) ) updated = await get_session(session_id) or session return { "status": "updated", "outfit_json": updated.get("outfit_json", "[]"), "scene_json": updated.get("scene_json", "{}"), "affinity": updated.get("affinity", 0), "narrative_stats": parse_stats_json(updated.get("narrative_stats_json")), } @router.patch("/{session_id}/rpg-state") async def patch_rpg_state(session_id: str, body: RpgStateDebugPatch): """Debug: set affinity and/or narrative stats (lust/stamina/tension 0–10).""" session = await get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Сессия не найдена") affinity = session.get("affinity", 0) if body.affinity is not None: affinity = await set_session_affinity(session_id, body.affinity) stats = parse_stats_json(session.get("narrative_stats_json")) changed_stats = False for key in ("lust", "stamina", "tension"): val = getattr(body, key, None) if val is not None: stats[key] = max(0, min(10, int(val))) changed_stats = True if changed_stats: import json as _json await update_session_narrative_stats( session_id, _json.dumps(stats, ensure_ascii=False) ) return { "affinity": affinity, "narrative_stats": stats, "target": "current_player", } @router.patch("/{session_id}/quests/{quest_id}") async def patch_quest(session_id: str, quest_id: int, body: QuestStatusPatch): status = body.status.strip() if status not in ("active", "done", "failed"): raise HTTPException(status_code=400, detail="status must be active, done, or failed") ok = await update_quest_by_id(quest_id, session_id, status) if not ok: raise HTTPException(status_code=404, detail="Quest not found") if status in ("done", "failed"): session = await get_session(session_id) if session and session.get("rpg_enabled"): persona = await get_persona(session.get("persona_id") or "default") or {} await reconcile_plot_arc( session_id, persona_name=persona.get("name", session.get("persona_id") or "Character"), recent_context=(session.get("status_quo") or "")[:2000], genre=session.get("genre") or "adventure", ) return {"status": "updated", "quest_id": quest_id, "new_status": status} @router.get("/{session_id}") async def get_session_route(session_id: str): s = await get_session(session_id) if not s: raise HTTPException(status_code=404, detail="Сессия не найдена") return s @router.post("/{session_id}/rebind-persona") async def rebind_persona(session_id: str, body: RebindPersonaRequest): session = await get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Сессия не найдена") persona = await get_persona(body.persona_id) if not persona: raise HTTPException(status_code=400, detail="Персонаж не найден") hist = [] if body.clear_history else await get_history(session_id) static = await get_system_prompt(body.persona_id, hist, "") first_mes = (persona.get("first_mes") or "").strip() if body.clear_history else None try: await rebind_session_persona( session_id, body.persona_id, clear_history=body.clear_history, static_prompt=static, first_mes=first_mes or None, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return { "persona_id": body.persona_id, "persona_name": persona.get("name", body.persona_id), "system_prompt_preview": static[:500], "clear_history": body.clear_history, } @router.patch("/{session_id}") async def patch_session(session_id: str, data: dict): create_pid = data.get("persona_id") if "persona_id" in data else None await get_or_create_session(session_id, create_pid) if "title" in data: await update_session_title(session_id, data["title"]) if "persona_id" in data: await update_session_persona(session_id, data["persona_id"]) if "rpg_enabled" in data: await update_session_rpg(session_id, bool(data["rpg_enabled"])) if "facts_json" in data: await update_session_facts(session_id, data["facts_json"]) if "global_plot" in data: await update_session_global_plot(session_id, data["global_plot"]) if "status_quo" in data: await update_session_status_quo(session_id, data["status_quo"]) if "genre" in data: await update_session_genre(session_id, data["genre"]) if "rpg_settings_json" in data: await update_session_rpg_settings(session_id, data["rpg_settings_json"]) return {"status": "updated"} @router.post("/{session_id}/fork") async def fork_session_route(session_id: str, req: ForkSessionRequest): new_id = await fork_session(session_id, req.until_message_id) if not new_id: raise HTTPException(status_code=404, detail="Сессия не найдена") return {"session_id": new_id, "source_session_id": session_id} @router.delete("/{session_id}") async def remove_session(session_id: str): await delete_session(session_id) return {"status": "deleted", "session_id": session_id}