diff --git a/README.md b/README.md index 205fc2c..2681dbc 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,63 @@ curl -X POST "http://127.0.0.1:8000/search" \ --- +### POST /chat + +Ask a natural question about saved memories. Bepo retrieves the most relevant memories and returns a simple, friendly answer built from the top match's metadata. **This is a local, deterministic chat-lite endpoint — it does not call any LLM or external API.** + +**Request body (JSON):** +- `message` (string, required): Your question or description (must not be empty or whitespace-only) +- `top_k` (int, optional, default 3, min 1, max 10): Number of memories to retrieve + +**Example:** +```bash +curl -X POST "http://127.0.0.1:8000/chat" \ + -H "Content-Type: application/json" \ + -d '{"message": "Where was that calm cafe with the cat?", "top_k": 3}' +``` + +**Response (with results):** +```json +{ + "status": "success", + "message": "Where was that calm cafe with the cat?", + "answer": "You may mean the memory near the red couch hallway. I remember it as calm, cafe, cat, cozy.", + "count": 1, + "memories": [ + { + "id": 1, + "timestamp": "2024-01-01T12:00:00.000000", + "note": null, + "user_note": null, + "bepo_summary": null, + "tags": "cafe,cat,cozy", + "mood": "calm", + "place_hint": "near the red couch hallway", + "lat": null, + "lon": null, + "image_path": "images/20240101_120000_000000.jpg", + "image_url": "/image/1", + "map_url": null, + "score": 0.72 + } + ] +} +``` + +**Response (empty database):** +```json +{ + "status": "no_results", + "message": "Where was that calm cafe with the cat?", + "answer": "I do not have any memories saved yet.", + "memories": [] +} +``` + +The answer is built locally from the top memory's metadata (place hint, mood, tags, note/summary). No OpenAI or external API is involved. + +--- + ### GET / Returns API version information and a list of available endpoints. diff --git a/main.py b/main.py index 12c05ad..0e8eba0 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from PIL import Image from fastapi import FastAPI, UploadFile, File, Form, HTTPException from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator import uvicorn # Database configuration @@ -59,6 +59,18 @@ class MemoryMetadataUpdate(BaseModel): mood: Optional[str] = None place_hint: Optional[str] = None + +class ChatRequest(BaseModel): + message: str + top_k: int = Field(default=3, ge=1, le=10) + + @field_validator("message") + @classmethod + def message_not_whitespace(cls, v: str) -> str: + if not v.strip(): + raise ValueError("message must not be empty or whitespace") + return v + def init_model(): """Initialize CLIP model for embeddings""" global model, processor, USE_CLIP @@ -352,6 +364,87 @@ def deserialize_embedding(data: bytes) -> np.ndarray: buffer = io.BytesIO(data) return np.load(buffer) + +def search_memory_matches(query: str, top_k: int) -> list: + """Return up to top_k scored memory dicts for query, sorted by score descending.""" + query_emb = get_text_embedding(query) + + conn = get_db_connection() + try: + cursor = conn.cursor() + cursor.execute( + "SELECT id, ts, lat, lon, image_path, image_emb, text_note, text_emb, " + "user_note, bepo_summary, tags, mood, place_hint " + "FROM memories" + ) + rows = cursor.fetchall() + finally: + conn.close() + + if not rows: + return [] + + scored = [] + for row in rows: + image_emb = deserialize_embedding(row["image_emb"]) + image_score = cosine_similarity(query_emb, image_emb) + + text_score = -1.0 + if row["text_emb"] is not None: + text_emb_arr = deserialize_embedding(row["text_emb"]) + text_score = cosine_similarity(query_emb, text_emb_arr) + + score = max(image_score, text_score) + memory_id = row["id"] + lat = row["lat"] + lon = row["lon"] + scored.append({ + "id": memory_id, + "timestamp": row["ts"], + "image_path": row["image_path"], + "image_url": build_image_url(memory_id), + "note": row["text_note"], + "user_note": row["user_note"], + "bepo_summary": row["bepo_summary"], + "tags": row["tags"], + "mood": row["mood"], + "place_hint": row["place_hint"], + "lat": lat, + "lon": lon, + "map_url": build_map_url(lat, lon), + "score": score, + }) + + scored.sort(key=lambda x: x["score"], reverse=True) + return scored[:top_k] + + +def build_chat_answer(top: dict) -> str: + """Build a simple deterministic answer from the top memory match.""" + sentences = [] + + if top.get("place_hint"): + sentences.append(f"You may mean the memory near {top['place_hint']}.") + else: + sentences.append("You may mean this memory.") + + description = top.get("bepo_summary") or top.get("user_note") or top.get("note") + if description: + sentences.append(f'I recall: "{description}".') + + details = [] + if top.get("mood"): + details.append(top["mood"]) + if top.get("tags"): + details.extend(t.strip() for t in top["tags"].split(",") if t.strip()) + if details: + sentences.append(f"I remember it as {', '.join(details)}.") + + if top.get("map_url"): + sentences.append("A map link is available.") + + return " ".join(sentences) + @app.post("/memory") async def create_memory( photo: UploadFile = File(...), @@ -513,72 +606,21 @@ async def search_memories( Search memories by text query. Returns up to *top_k* matches sorted by score descending. """ - # Validate inputs if not query or not query.strip(): raise HTTPException(status_code=422, detail="query must not be empty or whitespace") if not (1 <= top_k <= 20): raise HTTPException(status_code=422, detail="top_k must be between 1 and 20") try: - # Generate query embedding - query_emb = get_text_embedding(query) - - # Retrieve all memories with embeddings - conn = get_db_connection() - try: - cursor = conn.cursor() - cursor.execute( - "SELECT id, ts, lat, lon, image_path, image_emb, text_note, text_emb, " - "user_note, bepo_summary, tags, mood, place_hint " - "FROM memories" - ) - rows = cursor.fetchall() - finally: - conn.close() + matches = search_memory_matches(query.strip(), top_k) - if not rows: + if not matches: return { "status": "no_results", "message": "No memories found in database", "matches": [], } - # Score every memory - scored = [] - for row in rows: - image_emb = deserialize_embedding(row["image_emb"]) - image_score = cosine_similarity(query_emb, image_emb) - - text_score = -1.0 - if row["text_emb"] is not None: - text_emb = deserialize_embedding(row["text_emb"]) - text_score = cosine_similarity(query_emb, text_emb) - - score = max(image_score, text_score) - memory_id = row["id"] - lat = row["lat"] - lon = row["lon"] - scored.append({ - "id": memory_id, - "timestamp": row["ts"], - "image_path": row["image_path"], - "image_url": build_image_url(memory_id), - "note": row["text_note"], - "user_note": row["user_note"], - "bepo_summary": row["bepo_summary"], - "tags": row["tags"], - "mood": row["mood"], - "place_hint": row["place_hint"], - "lat": lat, - "lon": lon, - "map_url": build_map_url(lat, lon), - "score": score, - }) - - # Sort by score descending and take top_k - scored.sort(key=lambda x: x["score"], reverse=True) - matches = scored[:top_k] - return { "status": "success", "query": query, @@ -592,6 +634,37 @@ async def search_memories( raise HTTPException(status_code=500, detail=f"Error searching memories: {str(e)}") +@app.post("/chat") +async def chat(request: ChatRequest): + """ + Ask a natural question about saved memories. + Returns a simple deterministic answer built from the top matching memory. + """ + try: + matches = search_memory_matches(request.message.strip(), request.top_k) + + if not matches: + return { + "status": "no_results", + "message": request.message, + "answer": "I do not have any memories saved yet.", + "memories": [], + } + + answer = build_chat_answer(matches[0]) + + return { + "status": "success", + "message": request.message, + "answer": answer, + "count": len(matches), + "memories": matches, + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error in chat: {str(e)}") + + @app.get("/memories") async def list_memories(): """Return all saved memories, newest first. Embeddings are not included.""" @@ -635,7 +708,7 @@ async def root(): """Root endpoint with API information""" return { "app": "Bepo", - "version": "0.4", + "version": "0.5", "description": "Memory storage and search with image and text embeddings", "endpoints": { "POST /memory": "Store a new memory with photo, note, and GPS", @@ -644,6 +717,7 @@ async def root(): "GET /memory/{id}": "Get a single memory by id", "GET /image/{id}": "Serve the image for a memory", "POST /search": "Search memories by text query (supports top_k parameter)", + "POST /chat": "Ask a natural question about saved memories", }, } diff --git a/tests/test_api.py b/tests/test_api.py index 2c70d2e..78bd502 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -459,6 +459,90 @@ def test_top_k_out_of_range_rejected(self, client): assert r2.status_code == 422 +class TestChat: + def test_no_results_on_empty_database(self, client): + r = client.post("/chat", json={"message": "Where was the calm cafe?"}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "no_results" + assert data["memories"] == [] + assert data["answer"] == "I do not have any memories saved yet." + + def test_rejects_empty_message(self, client): + r = client.post("/chat", json={"message": ""}) + assert r.status_code == 422 + + def test_rejects_whitespace_only_message(self, client): + r = client.post("/chat", json={"message": " "}) + assert r.status_code == 422 + + def test_rejects_top_k_below_min(self, client): + r = client.post("/chat", json={"message": "test", "top_k": 0}) + assert r.status_code == 422 + + def test_rejects_top_k_above_max(self, client): + r = client.post("/chat", json={"message": "test", "top_k": 11}) + assert r.status_code == 422 + + def test_returns_relevant_memory_after_creating_one(self, client): + client.post( + "/memory", + files={"photo": ("img.jpg", _tiny_jpeg(), "image/jpeg")}, + data={"note": "quiet cafe with a cat", "mood": "calm", "tags": "cafe,cat"}, + ) + r = client.post("/chat", json={"message": "cafe with a cat"}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "success" + assert data["count"] >= 1 + assert len(data["memories"]) >= 1 + assert data["memories"][0]["note"] == "quiet cafe with a cat" + + def test_answer_includes_useful_metadata_from_top_memory(self, client): + client.post( + "/memory", + files={"photo": ("img.jpg", _tiny_jpeg(), "image/jpeg")}, + data={ + "mood": "calm", + "tags": "cafe,cat,cozy", + "place_hint": "near the red couch hallway", + }, + ) + r = client.post("/chat", json={"message": "calm cafe with a cat"}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "success" + answer = data["answer"] + # Answer should reference the place hint and/or mood/tags + assert any(kw in answer for kw in ["red couch", "calm", "cafe", "cat", "cozy"]) + + def test_does_not_expose_embeddings(self, client): + client.post( + "/memory", + files={"photo": ("img.jpg", _tiny_jpeg(), "image/jpeg")}, + data={"note": "test memory"}, + ) + r = client.post("/chat", json={"message": "test"}) + assert r.status_code == 200 + data = r.json() + for memory in data["memories"]: + assert "image_emb" not in memory + assert "text_emb" not in memory + + def test_search_still_works_after_helper_refactor(self, client): + client.post( + "/memory", + files={"photo": ("img.jpg", _tiny_jpeg(), "image/jpeg")}, + data={"note": "ocean at dusk"}, + ) + r = client.post("/search", data={"query": "ocean", "top_k": "3"}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "success" + assert data["count"] >= 1 + assert data["matches"][0]["note"] == "ocean at dusk" + + class TestBuildMapUrl: def test_returns_url_when_coords_present(self): url = app_module.build_map_url(34.0522, -118.2437)