fixed
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import chat, health, pomodoro
|
||||
from app.api.routes import character, chat, health, pomodoro
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||
api_router.include_router(character.router, tags=["character"])
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.character.service import CharacterService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CharacterCardData(BaseModel):
|
||||
name: str = "Ассистент"
|
||||
description: str = ""
|
||||
personality: str = ""
|
||||
scenario: str = ""
|
||||
first_mes: str = ""
|
||||
mes_example: str = ""
|
||||
system_prompt: str = ""
|
||||
post_history_instructions: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
creator: str = ""
|
||||
creator_notes: str = ""
|
||||
alternate_greetings: list[str] = Field(default_factory=list)
|
||||
character_version: str = "1.0"
|
||||
|
||||
|
||||
class CharacterCardV2(BaseModel):
|
||||
spec: str = "chara_card_v2"
|
||||
spec_version: str = "2.0"
|
||||
data: CharacterCardData
|
||||
|
||||
|
||||
@router.get("/character")
|
||||
def get_character() -> dict[str, Any]:
|
||||
return CharacterService().get_card()
|
||||
|
||||
|
||||
@router.put("/character")
|
||||
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
|
||||
return CharacterService().save_card(payload.model_dump())
|
||||
|
||||
|
||||
@router.get("/character/prompt")
|
||||
def get_character_prompt() -> dict[str, str]:
|
||||
service = CharacterService()
|
||||
return {
|
||||
"system_prompt": service.get_system_prompt(),
|
||||
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/character/import")
|
||||
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="Empty card")
|
||||
return CharacterService().save_card(payload)
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.character.service import CharacterService
|
||||
|
||||
__all__ = ["CharacterService"]
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import Any
|
||||
|
||||
TOOLS_INSTRUCTIONS = """
|
||||
Ты также домашний ассистент с инструментами. Обязательные правила:
|
||||
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
|
||||
- Никогда не выдумывай статус таймера или список задач.
|
||||
- После вызова инструмента кратко объясни результат пользователю по-человечески.
|
||||
- Инструменты: get_pomodoro_status, start_pomodoro, stop_pomodoro, get_pomodoro_history.
|
||||
""".strip()
|
||||
|
||||
DEFAULT_CARD: dict[str, Any] = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "Домашний ассистент",
|
||||
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
|
||||
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
|
||||
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
|
||||
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
|
||||
"mes_example": "",
|
||||
"system_prompt": "",
|
||||
"post_history_instructions": "",
|
||||
"alternate_greetings": [],
|
||||
"tags": ["assistant", "home", "pomodoro"],
|
||||
"creator": "",
|
||||
"creator_notes": "",
|
||||
"character_version": "1.0",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
if "data" in raw and isinstance(raw["data"], dict):
|
||||
card = {
|
||||
"spec": raw.get("spec", "chara_card_v2"),
|
||||
"spec_version": raw.get("spec_version", "2.0"),
|
||||
"data": {**DEFAULT_CARD["data"], **raw["data"]},
|
||||
}
|
||||
return card
|
||||
|
||||
if "name" in raw or "description" in raw:
|
||||
return {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {**DEFAULT_CARD["data"], **raw},
|
||||
}
|
||||
|
||||
return DEFAULT_CARD.copy()
|
||||
|
||||
|
||||
def build_system_prompt(card: dict[str, Any]) -> str:
|
||||
data = card.get("data", {})
|
||||
parts: list[str] = []
|
||||
|
||||
name = data.get("name", "Ассистент")
|
||||
parts.append(f"Ты — {name}.")
|
||||
|
||||
if data.get("system_prompt"):
|
||||
parts.append(data["system_prompt"])
|
||||
if data.get("description"):
|
||||
parts.append(data["description"])
|
||||
if data.get("personality"):
|
||||
parts.append(f"Характер: {data['personality']}")
|
||||
if data.get("scenario"):
|
||||
parts.append(f"Сценарий: {data['scenario']}")
|
||||
if data.get("post_history_instructions"):
|
||||
parts.append(data["post_history_instructions"])
|
||||
|
||||
parts.append(TOOLS_INSTRUCTIONS)
|
||||
return "\n\n".join(part for part in parts if part.strip())
|
||||
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
|
||||
|
||||
CARD_PATH = Path("./data/character.json")
|
||||
|
||||
|
||||
class CharacterService:
|
||||
def get_card(self) -> dict[str, Any]:
|
||||
if CARD_PATH.is_file():
|
||||
try:
|
||||
raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
|
||||
return normalize_card(raw)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return normalize_card(DEFAULT_CARD)
|
||||
|
||||
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
card = normalize_card(raw)
|
||||
CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return card
|
||||
|
||||
def get_system_prompt(self) -> str:
|
||||
return build_system_prompt(self.get_card())
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _format_time(seconds: int) -> str:
|
||||
minutes, secs = divmod(max(0, seconds), 60)
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
return f"⏱ Помидоро: {data['error']}"
|
||||
|
||||
if tool_name in ("get_pomodoro_status", "start_pomodoro", "stop_pomodoro"):
|
||||
return _format_status_notice(data)
|
||||
|
||||
if tool_name == "get_pomodoro_history":
|
||||
return _format_history_notice(data)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
||||
status = data.get("status", "idle")
|
||||
task = data.get("task_note") or "без описания"
|
||||
remaining = data.get("remaining_seconds", 0)
|
||||
duration = data.get("duration_min", 25)
|
||||
|
||||
if status == "idle":
|
||||
return "⏱ **Помидоро:** таймер не запущен."
|
||||
|
||||
if status == "running":
|
||||
return (
|
||||
f"⏱ **Помидоро запущен** · осталось **{_format_time(remaining)}** "
|
||||
f"из {duration} мин · задача: _{task}_"
|
||||
)
|
||||
|
||||
if status == "paused":
|
||||
elapsed = data.get("elapsed_seconds", 0)
|
||||
return (
|
||||
f"⏱ **Помидоро на паузе** · прошло {_format_time(elapsed)} "
|
||||
f"из {duration} мин · задача: _{task}_"
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return f"⏱ **Помидоро завершён** · {duration} мин · задача: _{task}_"
|
||||
|
||||
if status == "cancelled":
|
||||
return f"⏱ **Помидоро отменён** · задача: _{task}_"
|
||||
|
||||
return f"⏱ Помидоро: {status}"
|
||||
|
||||
|
||||
def _format_history_notice(data: Any) -> str:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "⏱ **История помидоро** пуста."
|
||||
|
||||
lines = ["⏱ **История помидоро:**"]
|
||||
for item in data[:10]:
|
||||
task = item.get("task_note") or "без описания"
|
||||
status = item.get("status", "?")
|
||||
duration = item.get("duration_min", "?")
|
||||
lines.append(f"- {task} ({duration} мин, {status})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_pomodoro_context(status: dict[str, Any]) -> str:
|
||||
notice = _format_status_notice(status)
|
||||
return f"[Актуальный статус помидоро]\n{notice}"
|
||||
@@ -5,9 +5,11 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notices import format_pomodoro_context, format_pomodoro_notice
|
||||
from app.db.models import ChatSession, Message
|
||||
from app.llm.client import LLMClient
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
@@ -17,7 +19,7 @@ class ChatService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.llm = LLMClient()
|
||||
self.system_prompt = get_settings().load_system_prompt()
|
||||
self.character = CharacterService()
|
||||
|
||||
def list_sessions(self) -> list[ChatSession]:
|
||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
||||
@@ -41,9 +43,21 @@ class ChatService:
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
status = PomodoroService(self.db).get_status()
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
f"{format_pomodoro_context(status)}"
|
||||
)
|
||||
|
||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": self.system_prompt}]
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": self._build_system_prompt()}
|
||||
]
|
||||
for msg in session.messages:
|
||||
if msg.role == "notice":
|
||||
continue
|
||||
|
||||
content = msg.content or None
|
||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||
if msg.tool_calls_json:
|
||||
@@ -123,7 +137,16 @@ class ChatService:
|
||||
}
|
||||
messages.append(tool_message)
|
||||
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
||||
yield self._sse("tool", {"name": fn["name"], "result": json.loads(result)})
|
||||
|
||||
notice = format_pomodoro_notice(fn["name"], result)
|
||||
if notice:
|
||||
self._save_message(session_id, "notice", notice)
|
||||
yield self._sse("notice", {"content": notice})
|
||||
|
||||
yield self._sse(
|
||||
"pomodoro",
|
||||
{"name": fn["name"], "result": json.loads(result)},
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_status",
|
||||
"description": "Получить текущий статус помидоро-таймера",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
@@ -18,7 +18,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_pomodoro",
|
||||
"description": "Запустить помидоро-таймер",
|
||||
"description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -60,7 +60,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_history",
|
||||
"description": "Получить историю завершённых помидоро-сессий",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user