ai-skills-api/main.py
Lukas Parsons 7f7699ff94 Initial commit: Skills API with MCP servers
- 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
2026-03-22 21:18:23 -04:00

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"}