diff --git a/README.md b/README.md index 393cb89..ed61994 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This project provides a scalable API backend using FastAPI and PostgreSQL, featuring: - Automatic full-text search on all text fields (via tsvector) -- Endpoints for health checks, product management, prompt handling, and prospect management +- Endpoints for health checks, product management, prompt handling (via `/prompt`), resend email, and prospect management - Efficient ingestion and processing of large CSV files #### 🚀 Features @@ -21,7 +21,7 @@ This project provides a scalable API backend using FastAPI and PostgreSQL, featu - **Uvicorn** — Lightning-fast ASGI server - **Pytest** — Comprehensive testing -#### Getting Started +#### Install & Use ### 1. Clone & Setup Environment @@ -42,6 +42,7 @@ uvicorn app.main:app --reload Visit [localhost:8000](http://localhost:8000) or [onrender](https://nx-ai.onrender.com) + #### API Documentation FastAPI auto-generates interactive docs: @@ -49,6 +50,13 @@ FastAPI auto-generates interactive docs: - [Swagger UI](https://nx-ai.onrender.com/docs) - [ReDoc](https://nx-ai.onrender.com/redoc) +#### Notable Endpoints + +- `GET /health` — Health check +- `GET/POST /prompt` — LLM prompt completion (formerly `/llm`) +- `GET/POST /resend` — Send email via Resend API (see implementation in `app/utils/notify/resend.py`) +- `GET /prospects` — Paginated prospects +- `POST /prospects/process` — Bulk CSV ingestion ## Full-Text Search (tsvector) diff --git a/app/__init__.py b/app/__init__.py index 6393ed7..f87b917 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """Python - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "2.2.2" +__version__ = "2.2.3" diff --git a/app/api/prompt/__init__.py b/app/api/prompt/__init__.py index 227ad88..7ba875d 100644 --- a/app/api/prompt/__init__.py +++ b/app/api/prompt/__init__.py @@ -1,3 +1,4 @@ -"""LLM Routes""" +"""Prompt Routes""" -from .prompt import router as llm_router +from .prompt import router as prompt_router +from .linkedin import router as linkedin_router diff --git a/app/api/prompt/linkedin.py b/app/api/prompt/linkedin.py new file mode 100644 index 0000000..a054b13 --- /dev/null +++ b/app/api/prompt/linkedin.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.utils.api_key_auth import get_api_key +from app.utils.db import get_db_connection_direct +from app.utils.make_meta import make_meta + +router = APIRouter() + + +@router.post("/prompt/linkedin") +def linkedin_prompt_success(payload: dict, api_key: str = Depends(get_api_key)) -> dict: + """POST /prompt/linkedin: return cached completion for linkedinUrl when available.""" + linkedin_url = (payload.get("linkedinUrl") or "").strip() + if not linkedin_url: + raise HTTPException(status_code=400, detail="Missing 'linkedinUrl' in request body.") + + conn = None + cur = None + try: + conn = get_db_connection_direct() + cur = conn.cursor() + cur.execute( + """ + SELECT id, completion, time, model, data + FROM prompt + WHERE (data->>'linkedinUrl' = %s OR prompt ILIKE %s) + ORDER BY id DESC + LIMIT 1; + """, + (linkedin_url, f"%{linkedin_url}%"), + ) + row = cur.fetchone() + + if row: + return { + "meta": make_meta("success", "LinkedIn URL already analysed"), + "data": { + "cached": True, + "id": row[0], + "linkedinUrl": linkedin_url, + "completion": row[1], + "time": row[2].isoformat() if row[2] else None, + "model": row[3], + "record_data": row[4], + }, + } + + return { + "meta": make_meta("warning", "LinkedIn URL not analysed yet"), + "data": { + "cached": False, + "linkedinUrl": linkedin_url, + "completion": None, + }, + } + except HTTPException: + raise + except Exception as e: + return { + "meta": make_meta("error", f"DB error: {str(e)}"), + "data": {}, + } + finally: + if cur: + cur.close() + if conn: + conn.close() diff --git a/app/api/prompt/prompt.py b/app/api/prompt/prompt.py index 6264e80..d507eab 100644 --- a/app/api/prompt/prompt.py +++ b/app/api/prompt/prompt.py @@ -21,8 +21,8 @@ def get_prompt_records( if prospect_id is not None: # No pagination for single prospect_id lookup select_query = """ - SELECT id, prompt, completion, duration, time, data, model, prospect_id, search_vector - FROM llm + SELECT id, prompt, completion, duration, time, data, model, prospect_id + FROM prompt WHERE prospect_id = %s ORDER BY id DESC """ @@ -38,7 +38,6 @@ def get_prompt_records( "data": row[5], "model": row[6], "prospect_id": row[7], - "search_vector": str(row[8]) if row[8] is not None else None, } for row in rows ] @@ -58,12 +57,12 @@ def get_prompt_records( } else: offset = (page - 1) * page_size - cur.execute("SELECT COUNT(*) FROM llm;") + cur.execute("SELECT COUNT(*) FROM prompt;") count_row = cur.fetchone() total = count_row[0] if count_row and count_row[0] is not None else 0 cur.execute(""" - SELECT id, prompt, completion, duration, time, data, model, prospect_id, search_vector - FROM llm + SELECT id, prompt, completion, duration, time, data, model, prospect_id + FROM prompt ORDER BY id DESC LIMIT %s OFFSET %s; """, (page_size, offset)) @@ -77,13 +76,12 @@ def get_prompt_records( "data": row[5], "model": row[6], "prospect_id": row[7], - "search_vector": str(row[8]) if row[8] is not None else None, } for row in cur.fetchall() ] cur.close() conn.close() - meta = make_meta("success", f"LLM {len(records)} records (page {page})") + meta = make_meta("success", f"Prompt {len(records)} records (page {page})") return { "meta": meta, "data": { @@ -140,7 +138,7 @@ def llm_post(payload: dict) -> dict: if not completion: error_details = " | ".join([f"{k}: {v}" for k, v in errors.items()]) raise Exception(f"No available Gemini model succeeded for generate_content with your API key. Details: {error_details}") - # Insert record into llm table + # Insert record into prompt table record_id = None try: import json @@ -148,14 +146,13 @@ def llm_post(payload: dict) -> dict: data_blob = json.dumps({"version": __version__}) conn = get_db_connection_direct() cur = conn.cursor() - # Generate tsvector from prompt and completion cur.execute( """ - INSERT INTO llm (prompt, completion, duration, data, model, prospect_id, search_vector) - VALUES (%s, %s, %s, %s, %s, %s, to_tsvector('english', %s || ' ' || %s)) + INSERT INTO prompt (prompt, completion, duration, data, model, prospect_id) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id; """, - (prompt, completion, duration, data_blob, used_model, prospect_id, prompt, completion) + (prompt, completion, duration, data_blob, used_model, prospect_id) ) record_id_row = cur.fetchone() record_id = record_id_row[0] if record_id_row else None @@ -164,7 +161,7 @@ def llm_post(payload: dict) -> dict: conn.close() except Exception as db_exc: # Log DB error but do not fail the API response - logging.error(f"Failed to insert llm record: {db_exc}") + logging.error(f"Failed to insert prompt record: {db_exc}") meta = make_meta("success", f"Gemini completion received from {used_model}") return {"meta": meta, "data": {"id": record_id, "prompt": prompt, "completion": completion}} except Exception as e: diff --git a/app/api/prompt/sql/alter_add_prompt_code.sql b/app/api/prompt/sql/alter_add_prompt_code.sql deleted file mode 100644 index 80834b4..0000000 --- a/app/api/prompt/sql/alter_add_prompt_code.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration: Add prompt_code column to llm table -ALTER TABLE llm ADD COLUMN IF NOT EXISTS prompt_code TEXT; \ No newline at end of file diff --git a/app/api/prompt/sql/alter_add_prospect_id.sql b/app/api/prompt/sql/alter_add_prospect_id.sql deleted file mode 100644 index 001a844..0000000 --- a/app/api/prompt/sql/alter_add_prospect_id.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration: Add prospect_id column to llm table -ALTER TABLE llm ADD COLUMN IF NOT EXISTS prospect_id INTEGER REFERENCES prospects(id); \ No newline at end of file diff --git a/app/api/prompt/sql/alter_add_search_vector.sql b/app/api/prompt/sql/alter_add_search_vector.sql deleted file mode 100644 index 9e5188f..0000000 --- a/app/api/prompt/sql/alter_add_search_vector.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Migration: Add search_vector tsvector column to llm table -ALTER TABLE llm ADD COLUMN IF NOT EXISTS search_vector tsvector; - --- Optional: Create a GIN index for faster search -CREATE INDEX IF NOT EXISTS idx_llm_search_vector ON llm USING GIN(search_vector); \ No newline at end of file diff --git a/app/api/prompt/sql/alter_add_type_column.sql b/app/api/prompt/sql/alter_add_type_column.sql deleted file mode 100644 index b8999d7..0000000 --- a/app/api/prompt/sql/alter_add_type_column.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration: Add 'type' column to llm table -ALTER TABLE llm ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'default'; diff --git a/app/api/prompt/sql/create_table.sql b/app/api/prompt/sql/create_table.sql index b53967d..70776ce 100644 --- a/app/api/prompt/sql/create_table.sql +++ b/app/api/prompt/sql/create_table.sql @@ -1,5 +1,5 @@ -CREATE TABLE IF NOT EXISTS llm ( +CREATE TABLE IF NOT EXISTS prompt ( id SERIAL PRIMARY KEY, vector vector(1536), prompt TEXT NOT NULL, diff --git a/app/api/prompt/sql/drop_llm_table.sql b/app/api/prompt/sql/drop_llm_table.sql new file mode 100644 index 0000000..99fbcfb --- /dev/null +++ b/app/api/prompt/sql/drop_llm_table.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS llm; \ No newline at end of file diff --git a/app/api/prompt/sql/empty_llm_table.py b/app/api/prompt/sql/empty_llm_table.py deleted file mode 100644 index 8b7d7cc..0000000 --- a/app/api/prompt/sql/empty_llm_table.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) -from app.utils.db import get_db_connection_direct - -if __name__ == "__main__": - sql = "DELETE FROM llm;" - conn = get_db_connection_direct() - cur = conn.cursor() - cur.execute(sql) - conn.commit() - cur.close() - conn.close() - print("All records deleted from llm table.") diff --git a/app/api/prompt/sql/run_alter_add_prompt_code.py b/app/api/prompt/sql/run_alter_add_prompt_code.py deleted file mode 100644 index 4b78232..0000000 --- a/app/api/prompt/sql/run_alter_add_prompt_code.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) -from app.utils.db import get_db_connection_direct - -if __name__ == "__main__": - sql = """ - ALTER TABLE llm ADD COLUMN IF NOT EXISTS prompt_code TEXT; - """ - conn = get_db_connection_direct() - cur = conn.cursor() - cur.execute(sql) - conn.commit() - cur.close() - conn.close() - print("Migration complete: prompt_code column added to llm table.") diff --git a/app/api/prompt/sql/run_alter_add_prospect_id.py b/app/api/prompt/sql/run_alter_add_prospect_id.py deleted file mode 100644 index 3393ce9..0000000 --- a/app/api/prompt/sql/run_alter_add_prospect_id.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) -from app.utils.db import get_db_connection_direct - -if __name__ == "__main__": - sql = """ - ALTER TABLE llm ADD COLUMN IF NOT EXISTS prospect_id INTEGER REFERENCES prospects(id); - """ - conn = get_db_connection_direct() - cur = conn.cursor() - cur.execute(sql) - conn.commit() - cur.close() - conn.close() - print("Migration complete: prospect_id column added to llm table.") diff --git a/app/api/prompt/sql/run_alter_add_search_vector.py b/app/api/prompt/sql/run_alter_add_search_vector.py deleted file mode 100644 index 5a58510..0000000 --- a/app/api/prompt/sql/run_alter_add_search_vector.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) -from app.utils.db import get_db_connection_direct - -if __name__ == "__main__": - sql = """ - ALTER TABLE llm ADD COLUMN IF NOT EXISTS search_vector tsvector; - CREATE INDEX IF NOT EXISTS idx_llm_search_vector ON llm USING GIN(search_vector); - """ - conn = get_db_connection_direct() - cur = conn.cursor() - cur.execute(sql) - conn.commit() - cur.close() - conn.close() - print("Migration complete: search_vector column and index added to llm table.") diff --git a/app/api/prompt/sql/run_alter_add_type_column.py b/app/api/prompt/sql/run_alter_add_type_column.py deleted file mode 100644 index 884ce76..0000000 --- a/app/api/prompt/sql/run_alter_add_type_column.py +++ /dev/null @@ -1,21 +0,0 @@ -# Run this script to add a 'type' column to the llm table if it doesn't exist -from app.utils.db import get_db_connection_direct - -def add_type_column(): - conn = get_db_connection_direct() - cur = conn.cursor() - try: - cur.execute(""" - ALTER TABLE llm ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'default'; - """) - conn.commit() - print("'type' column added to llm table (if not already present).") - except Exception as e: - print(f"Error adding 'type' column: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -if __name__ == "__main__": - add_type_column() diff --git a/app/api/prospects/LinkedInConnections.csv b/app/api/prospects/linkedin.csv similarity index 100% rename from app/api/prospects/LinkedInConnections.csv rename to app/api/prospects/linkedin.csv diff --git a/app/api/prospects/prospects.py b/app/api/prospects/prospects.py index 5b7d0c4..3856145 100644 --- a/app/api/prospects/prospects.py +++ b/app/api/prospects/prospects.py @@ -98,28 +98,27 @@ def prospects_read_one(id: int = Path(..., description="ID of the prospect to re if row is not None: columns = [desc[0] for desc in cur.description] data = dict(zip(columns, row)) - # Fetch related llm records + # Fetch related prompt records try: from app.utils.db import get_db_connection_direct - llm_conn = get_db_connection_direct() - llm_cur = llm_conn.cursor() - llm_cur.execute("SELECT id, duration, time, data, model, search_vector FROM llm WHERE prospect_id = %s ORDER BY id DESC;", (id,)) - llm_records = [ + prompt_conn = get_db_connection_direct() + prompt_cur = prompt_conn.cursor() + prompt_cur.execute("SELECT id, duration, time, data, model FROM prompt WHERE prospect_id = %s ORDER BY id DESC;", (id,)) + prompt_records = [ { "id": r[0], "duration": r[1], "time": r[2].isoformat() if r[2] else None, "data": r[3], "model": r[4], - "search_vector": str(r[5]) if r[5] is not None else None, } - for r in llm_cur.fetchall() + for r in prompt_cur.fetchall() ] - llm_cur.close() - llm_conn.close() - data["llm_records"] = llm_records - except Exception as llm_exc: - data["llm_records"] = [] + prompt_cur.close() + prompt_conn.close() + data["prompt_records"] = prompt_records + except Exception as prompt_exc: + data["prompt_records"] = [] else: data = None meta = make_meta("error", f"No prospect found with id {id}") diff --git a/app/api/root.py b/app/api/root.py index 26c52dc..c70b79a 100644 --- a/app/api/root.py +++ b/app/api/root.py @@ -22,23 +22,25 @@ def root() -> dict: endpoints = [ {"name": "health", "url": f"{base_url}/health"}, { - "name": "Orders°", + "name": "Prompt°", "endpoints": [ - {"name": "list", "url": f"{base_url}/orders"}, + {"name": "list", "url": f"{base_url}/prompt"}, + {"name": "linkedin", "url": f"{base_url}/prompt/linkedin"}, ] }, { - "name": "Prospects°", + "name": "Orders°", "endpoints": [ - {"name": "list", "url": f"{base_url}/prospects"}, + {"name": "list", "url": f"{base_url}/orders"}, ] }, { - "name": "Prompt°", + "name": "Prospects°", "endpoints": [ - {"name": "list", "url": f"{base_url}/prompt"}, + {"name": "list", "url": f"{base_url}/prospects"}, ] }, + {"name": "Docs", "url": f"{base_url}/docs"}, ] return {"meta": meta, "data": endpoints} diff --git a/app/api/routes.py b/app/api/routes.py index bcb8ba7..80757f1 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -9,6 +9,7 @@ from app.utils.health import router as health_router from app.utils.notify.resend import router as resend_router from app.api.prompt.prompt import router as prompt_router +from app.api.prompt.linkedin import router as linkedin_router from app.api.prospects.prospects import router as prospects_router from app.api.orders.orders import router as orders_router @@ -16,5 +17,6 @@ router.include_router(resend_router) router.include_router(health_router) router.include_router(prompt_router) +router.include_router(linkedin_router) router.include_router(prospects_router) router.include_router(orders_router)