initial_commit

This commit is contained in:
2026-06-01 20:16:53 +09:00
commit 903d34761c
31 changed files with 1123 additions and 0 deletions
View File
View File
+91
View File
@@ -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")
+33
View File
@@ -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()
View File
+9
View File
@@ -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(",")
+29
View File
@@ -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 ""
+44
View File
@@ -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()
View File
+23
View File
@@ -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)
+41
View File
@@ -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")
+30
View File
@@ -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"}