initial_commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Session, Message
|
||||
from app.core import memory as mem
|
||||
from app.core import llm
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
message: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
model: str
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
def list_sessions(db: DBSession = Depends(get_db)):
|
||||
return db.query(Session).order_by(Session.updated_at.desc()).all()
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
# セッション取得 or 新規作成
|
||||
if req.session_id:
|
||||
session = db.get(Session, req.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
else:
|
||||
session = Session(
|
||||
id=str(uuid.uuid4()),
|
||||
title=req.message[:40],
|
||||
model=req.model or DEFAULT_MODEL,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
# ユーザーメッセージを保存
|
||||
user_msg = Message(session_id=session.id, role="user", content=req.message)
|
||||
db.add(user_msg)
|
||||
db.commit()
|
||||
|
||||
context = mem.get_context_messages(db, session.id)
|
||||
|
||||
async def generate():
|
||||
# セッションIDをまず返す(新規セッション時にフロントへ通知)
|
||||
yield f"data: {{\"type\":\"session_id\",\"value\":\"{session.id}\"}}\n\n"
|
||||
|
||||
full_response = []
|
||||
async for chunk in llm.stream_chat(context, model=req.model or session.model):
|
||||
full_response.append(chunk)
|
||||
escaped = chunk.replace('"', '\\"').replace('\n', '\\n')
|
||||
yield f"data: {{\"type\":\"chunk\",\"value\":\"{escaped}\"}}\n\n"
|
||||
|
||||
# アシスタントメッセージを保存
|
||||
assistant_content = "".join(full_response)
|
||||
db.add(Message(session_id=session.id, role="assistant", content=assistant_content))
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
# メモリ要約チェック
|
||||
await mem.maybe_summarize(db, session)
|
||||
|
||||
yield "data: {\"type\":\"done\"}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Memory
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
class MemoryResponse(BaseModel):
|
||||
id: int
|
||||
session_id: str
|
||||
summary: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=list[MemoryResponse])
|
||||
def get_memories(session_id: str, db: DBSession = Depends(get_db)):
|
||||
return db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
|
||||
|
||||
@router.delete("/{memory_id}", status_code=204)
|
||||
def delete_memory(memory_id: int, db: DBSession = Depends(get_db)):
|
||||
memory = db.get(Memory, memory_id)
|
||||
if not memory:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
db.delete(memory)
|
||||
db.commit()
|
||||
@@ -0,0 +1,9 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./playchat.db")
|
||||
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
|
||||
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
|
||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import AsyncIterator
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
|
||||
litellm.drop_params = True
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
|
||||
|
||||
async def complete(messages: list[dict], model: str | None = None) -> str:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=False,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
@@ -0,0 +1,44 @@
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from app.db.models import Memory, Message, Session
|
||||
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
|
||||
from app.core import llm
|
||||
|
||||
|
||||
def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
"""短期メモリ(最近のメッセージ)と長期メモリ(要約)を結合してコンテキストを返す。"""
|
||||
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
|
||||
|
||||
context: list[dict] = []
|
||||
|
||||
if memories:
|
||||
combined_summary = "\n\n".join(m.summary for m in memories)
|
||||
context.append({
|
||||
"role": "system",
|
||||
"content": f"以下はこの会話の過去の要約です:\n\n{combined_summary}",
|
||||
})
|
||||
|
||||
context.extend({"role": m.role, "content": m.content} for m in messages)
|
||||
return context
|
||||
|
||||
|
||||
async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。"""
|
||||
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
|
||||
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
|
||||
return
|
||||
|
||||
# 古いメッセージ(閾値の半分)を要約対象にする
|
||||
half = MEMORY_SUMMARIZE_THRESHOLD // 2
|
||||
to_summarize = messages[:half]
|
||||
|
||||
summary_prompt = [
|
||||
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
|
||||
*[{"role": m.role, "content": m.content} for m in to_summarize],
|
||||
]
|
||||
summary_text = await llm.complete(summary_prompt)
|
||||
|
||||
db.add(Memory(session_id=session.id, summary=summary_text))
|
||||
for msg in to_summarize:
|
||||
db.delete(msg)
|
||||
db.commit()
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from app.core.config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
from app.db import models # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String, default="New Chat")
|
||||
model: Mapped[str] = mapped_column(String, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
|
||||
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
__tablename__ = "memories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="memories")
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.db.database import init_db
|
||||
from app.api import chat, memory
|
||||
|
||||
app = FastAPI(title="PlayChat API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(chat.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user