- FastAPI backend with SQLite (ai.db) - Tables: skills, snippets, conventions, cache, memory - MCP servers: homelab, gameservers, skills - Docker Compose setup - Seed data with 8 skills, 2 conventions, 2 snippets - Token savings patterns via context bundles and caching
394 lines
13 KiB
Python
394 lines
13 KiB
Python
from fastapi import FastAPI, HTTPException, Depends, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.exc import IntegrityError
|
|
import hashlib
|
|
import json
|
|
import os
|
|
|
|
from database import get_db, init_db
|
|
from models import Skill, Snippet, Convention, Cache, Memory
|
|
from schemas import (
|
|
SkillBase, Skill,
|
|
SnippetBase, Snippet,
|
|
ConventionBase, Convention,
|
|
CacheStore, Cache as CacheSchema,
|
|
MemoryBase, Memory as MemorySchema,
|
|
ContextBundle, CacheLookup
|
|
)
|
|
|
|
app = FastAPI(title="AI Skills API", description="Local infrastructure for AI context management")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
await init_db()
|
|
|
|
|
|
# ============== SKILLS ==============
|
|
|
|
@app.get("/skills", response_model=list[Skill])
|
|
async def list_skills(
|
|
category: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
query = select(Skill)
|
|
if category:
|
|
query = query.where(Skill.category == category)
|
|
result = await db.execute(query.order_by(Skill.name))
|
|
return result.scalars().all()
|
|
|
|
|
|
@app.get("/skills/search")
|
|
async def search_skills(
|
|
q: str,
|
|
category: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
query = select(Skill).where(
|
|
(Skill.name.ilike(f"%{q}%")) |
|
|
(Skill.content.ilike(f"%{q}%")) |
|
|
(Skill.tags.ilike(f"%{q}%"))
|
|
)
|
|
if category:
|
|
query = query.where(Skill.category == category)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
@app.get("/skills/{skill_id}", response_model=Skill)
|
|
async def get_skill(skill_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
|
skill = result.scalar_one_or_none()
|
|
if not skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
skill.usage_count += 1
|
|
await db.commit()
|
|
return skill
|
|
|
|
|
|
@app.post("/skills", response_model=Skill)
|
|
async def create_skill(skill: SkillBase, db: AsyncSession = Depends(get_db)):
|
|
db_skill = Skill(**skill.model_dump())
|
|
db.add(db_skill)
|
|
try:
|
|
await db.commit()
|
|
await db.refresh(db_skill)
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=400, detail="Skill with this ID already exists")
|
|
return db_skill
|
|
|
|
|
|
@app.put("/skills/{skill_id}", response_model=Skill)
|
|
async def update_skill(skill_id: str, skill: SkillBase, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
|
db_skill = result.scalar_one_or_none()
|
|
if not db_skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
for key, value in skill.model_dump().items():
|
|
setattr(db_skill, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(db_skill)
|
|
return db_skill
|
|
|
|
|
|
@app.delete("/skills/{skill_id}")
|
|
async def delete_skill(skill_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Skill).where(Skill.id == skill_id))
|
|
skill = result.scalar_one_or_none()
|
|
if not skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
await db.delete(skill)
|
|
await db.commit()
|
|
return {"deleted": skill_id}
|
|
|
|
|
|
# ============== SNIPPETS ==============
|
|
|
|
@app.get("/snippets", response_model=list[Snippet])
|
|
async def list_snippets(
|
|
category: Optional[str] = None,
|
|
language: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
query = select(Snippet)
|
|
if category:
|
|
query = query.where(Snippet.category == category)
|
|
if language:
|
|
query = query.where(Snippet.language == language)
|
|
result = await db.execute(query.order_by(Snippet.name))
|
|
return result.scalars().all()
|
|
|
|
|
|
@app.get("/snippets/{snippet_id}", response_model=Snippet)
|
|
async def get_snippet(snippet_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Snippet).where(Snippet.id == snippet_id))
|
|
snippet = result.scalar_one_or_none()
|
|
if not snippet:
|
|
raise HTTPException(status_code=404, detail="Snippet not found")
|
|
return snippet
|
|
|
|
|
|
@app.post("/snippets", response_model=Snippet)
|
|
async def create_snippet(snippet: SnippetBase, db: AsyncSession = Depends(get_db)):
|
|
db_snippet = Snippet(**snippet.model_dump())
|
|
db.add(db_snippet)
|
|
try:
|
|
await db.commit()
|
|
await db.refresh(db_snippet)
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=400, detail="Snippet with this ID already exists")
|
|
return db_snippet
|
|
|
|
|
|
@app.delete("/snippets/{snippet_id}")
|
|
async def delete_snippet(snippet_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Snippet).where(Snippet.id == snippet_id))
|
|
snippet = result.scalar_one_or_none()
|
|
if not snippet:
|
|
raise HTTPException(status_code=404, detail="Snippet not found")
|
|
|
|
await db.delete(snippet)
|
|
await db.commit()
|
|
return {"deleted": snippet_id}
|
|
|
|
|
|
# ============== CONVENTIONS ==============
|
|
|
|
@app.get("/conventions", response_model=list[Convention])
|
|
async def list_conventions(
|
|
project: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
query = select(Convention)
|
|
if project:
|
|
query = query.where(Convention.project_path == project)
|
|
result = await db.execute(query.order_by(Convention.name))
|
|
return result.scalars().all()
|
|
|
|
|
|
@app.get("/conventions/{convention_id}", response_model=Convention)
|
|
async def get_convention(convention_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Convention).where(Convention.id == convention_id))
|
|
convention = result.scalar_one_or_none()
|
|
if not convention:
|
|
raise HTTPException(status_code=404, detail="Convention not found")
|
|
return convention
|
|
|
|
|
|
@app.post("/conventions", response_model=Convention)
|
|
async def create_convention(convention: ConventionBase, db: AsyncSession = Depends(get_db)):
|
|
db_convention = Convention(**convention.model_dump())
|
|
db.add(db_convention)
|
|
try:
|
|
await db.commit()
|
|
await db.refresh(db_convention)
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=400, detail="Convention with this ID already exists")
|
|
return db_convention
|
|
|
|
|
|
@app.put("/conventions/{convention_id}", response_model=Convention)
|
|
async def update_convention(convention_id: str, convention: ConventionBase, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Convention).where(Convention.id == convention_id))
|
|
db_convention = result.scalar_one_or_none()
|
|
if not db_convention:
|
|
raise HTTPException(status_code=404, detail="Convention not found")
|
|
|
|
for key, value in convention.model_dump().items():
|
|
setattr(db_convention, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(db_convention)
|
|
return db_convention
|
|
|
|
|
|
@app.delete("/conventions/{convention_id}")
|
|
async def delete_convention(convention_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Convention).where(Convention.id == convention_id))
|
|
convention = result.scalar_one_or_none()
|
|
if not convention:
|
|
raise HTTPException(status_code=404, detail="Convention not found")
|
|
|
|
await db.delete(convention)
|
|
await db.commit()
|
|
return {"deleted": convention_id}
|
|
|
|
|
|
# ============== CACHE ==============
|
|
|
|
@app.post("/cache/lookup", response_model=Optional[CacheSchema])
|
|
async def lookup_cache(lookup: CacheLookup, db: AsyncSession = Depends(get_db)):
|
|
prompt_hash = hashlib.sha256(
|
|
json.dumps({"prompt": lookup.prompt, "model": lookup.model}, sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
result = await db.execute(
|
|
select(Cache).where(
|
|
(Cache.hash == prompt_hash) &
|
|
((Cache.expires_at == None) | (Cache.expires_at > func.now()))
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
@app.post("/cache/store", response_model=CacheSchema)
|
|
async def store_cache(cache: CacheStore, db: AsyncSession = Depends(get_db)):
|
|
prompt_hash = hashlib.sha256(
|
|
json.dumps({"prompt": cache.response, "model": cache.model}, sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
db_cache = Cache(
|
|
hash=prompt_hash,
|
|
response=cache.response,
|
|
model=cache.model,
|
|
tokens_in=cache.tokens_in,
|
|
tokens_out=cache.tokens_out,
|
|
expires_at=cache.expires_at
|
|
)
|
|
db.add(db_cache)
|
|
await db.commit()
|
|
await db.refresh(db_cache)
|
|
return db_cache
|
|
|
|
|
|
@app.delete("/cache/{cache_hash}")
|
|
async def delete_cache(cache_hash: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Cache).where(Cache.hash == cache_hash))
|
|
cache = result.scalar_one_or_none()
|
|
if not cache:
|
|
raise HTTPException(status_code=404, detail="Cache entry not found")
|
|
|
|
await db.delete(cache)
|
|
await db.commit()
|
|
return {"deleted": cache_hash}
|
|
|
|
|
|
@app.get("/cache/stats")
|
|
async def cache_stats(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Cache))
|
|
entries = result.scalars().all()
|
|
return {
|
|
"total_entries": len(entries),
|
|
"total_tokens_saved": sum((c.tokens_in or 0) + (c.tokens_out or 0) for c in entries)
|
|
}
|
|
|
|
|
|
# ============== MEMORY ==============
|
|
|
|
@app.get("/memory", response_model=list[MemorySchema])
|
|
async def list_memory(
|
|
project: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
query = select(Memory)
|
|
if project:
|
|
query = query.where(Memory.project == project)
|
|
result = await db.execute(query.order_by(Memory.key))
|
|
return result.scalars().all()
|
|
|
|
|
|
@app.get("/memory/{memory_id}", response_model=MemorySchema)
|
|
async def get_memory(memory_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Memory).where(Memory.id == memory_id))
|
|
memory = result.scalar_one_or_none()
|
|
if not memory:
|
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
return memory
|
|
|
|
|
|
@app.post("/memory", response_model=MemorySchema)
|
|
async def create_memory(memory: MemoryBase, db: AsyncSession = Depends(get_db)):
|
|
db_memory = Memory(**memory.model_dump())
|
|
db.add(db_memory)
|
|
try:
|
|
await db.commit()
|
|
await db.refresh(db_memory)
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=400, detail="Memory with this ID already exists")
|
|
return db_memory
|
|
|
|
|
|
@app.put("/memory/{memory_id}", response_model=MemorySchema)
|
|
async def update_memory(memory_id: str, memory: MemoryBase, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Memory).where(Memory.id == memory_id))
|
|
db_memory = result.scalar_one_or_none()
|
|
if not db_memory:
|
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
|
|
for key, value in memory.model_dump().items():
|
|
setattr(db_memory, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(db_memory)
|
|
return db_memory
|
|
|
|
|
|
@app.delete("/memory/{memory_id}")
|
|
async def delete_memory(memory_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Memory).where(Memory.id == memory_id))
|
|
memory = result.scalar_one_or_none()
|
|
if not memory:
|
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
|
|
await db.delete(memory)
|
|
await db.commit()
|
|
return {"deleted": memory_id}
|
|
|
|
|
|
# ============== CONTEXT BUNDLE ==============
|
|
|
|
@app.get("/context", response_model=ContextBundle)
|
|
async def get_context(
|
|
project: Optional[str] = None,
|
|
skills: Optional[str] = Query(None, description="Comma-separated skill IDs to include"),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
skill_list = []
|
|
snippet_list = []
|
|
convention_list = []
|
|
memory_list = []
|
|
|
|
if skills:
|
|
skill_ids = [s.strip() for s in skills.split(",")]
|
|
result = await db.execute(select(Skill).where(Skill.id.in_(skill_ids)))
|
|
skill_list = result.scalars().all()
|
|
|
|
if project:
|
|
result = await db.execute(select(Convention).where(Convention.project_path == project))
|
|
convention_list = result.scalars().all()
|
|
|
|
result = await db.execute(select(Memory).where(Memory.project == project))
|
|
memory_list = result.scalars().all()
|
|
|
|
result = await db.execute(select(Snippet).where(Snippet.category == project.split("/")[-1]))
|
|
snippet_list = result.scalars().all()
|
|
|
|
return ContextBundle(
|
|
skills=skill_list,
|
|
snippets=snippet_list,
|
|
conventions=convention_list,
|
|
memories=memory_list
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy"}
|