From 9f15005fd78a4d2cea99484d8118cb00e5f26e4b Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Sun, 12 Apr 2026 12:22:47 +0100 Subject: [PATCH 1/4] Document /prompt and /resend endpoints in README Update README to reflect API changes: clarify prompt handling via /prompt (formerly /llm), add /resend endpoint for sending email via the Resend API (implementation at app/utils/notify/resend.py), and list prospects endpoints including POST /prospects/process for bulk CSV ingestion. Also add a Notable Endpoints section and minor formatting tweaks. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 393cb89..5f9f338 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 @@ -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) From fae4c0b6120ae28d89291716bb511e6c7c079c01 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Mon, 13 Apr 2026 13:01:29 +0100 Subject: [PATCH 2/4] Bump version; remove prompt SQL migrations Bump package version to 2.2.3, delete obsolete prompt-related SQL migration files and their runner/cleanup scripts under app/api/prompt/sql (prompt_code, prospect_id, search_vector, type column, and empty/run helpers). Also update README heading from 'Getting Started' to 'Install & Use' and rename app/api/prospects/LinkedInConnections.csv to app/api/prospects/linkedin.csv to standardize naming. These changes remove unused migration artifacts and tidy repository structure. --- README.md | 2 +- app/__init__.py | 2 +- app/api/prompt/sql/alter_add_prompt_code.sql | 2 -- app/api/prompt/sql/alter_add_prospect_id.sql | 2 -- .../prompt/sql/alter_add_search_vector.sql | 5 ----- app/api/prompt/sql/alter_add_type_column.sql | 2 -- app/api/prompt/sql/empty_llm_table.py | 14 ------------- .../prompt/sql/run_alter_add_prompt_code.py | 16 -------------- .../prompt/sql/run_alter_add_prospect_id.py | 16 -------------- .../prompt/sql/run_alter_add_search_vector.py | 17 --------------- .../prompt/sql/run_alter_add_type_column.py | 21 ------------------- .../{LinkedInConnections.csv => linkedin.csv} | 0 12 files changed, 2 insertions(+), 97 deletions(-) delete mode 100644 app/api/prompt/sql/alter_add_prompt_code.sql delete mode 100644 app/api/prompt/sql/alter_add_prospect_id.sql delete mode 100644 app/api/prompt/sql/alter_add_search_vector.sql delete mode 100644 app/api/prompt/sql/alter_add_type_column.sql delete mode 100644 app/api/prompt/sql/empty_llm_table.py delete mode 100644 app/api/prompt/sql/run_alter_add_prompt_code.py delete mode 100644 app/api/prompt/sql/run_alter_add_prospect_id.py delete mode 100644 app/api/prompt/sql/run_alter_add_search_vector.py delete mode 100644 app/api/prompt/sql/run_alter_add_type_column.py rename app/api/prospects/{LinkedInConnections.csv => linkedin.csv} (100%) diff --git a/README.md b/README.md index 5f9f338..ed61994 100644 --- a/README.md +++ b/README.md @@ -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 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/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/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 From a333948c60497795e5a4e56275ed18d3da2d54ef Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Mon, 13 Apr 2026 13:16:29 +0100 Subject: [PATCH 3/4] Add LinkedIn prompt endpoint and routes Introduce a new POST /prompt/linkedin stub (app/api/prompt/linkedin.py) that requires API key auth and returns a success meta payload. Register the new router in app/api/routes.py and update app/api/prompt/__init__.py imports. Update the root API index (app/api/root.py) to include the LinkedIn prompt entry and adjust the Prompt/Orders/Prospects listings to reflect the new endpoints. --- app/api/prompt/__init__.py | 3 ++- app/api/prompt/linkedin.py | 15 +++++++++++++++ app/api/root.py | 14 ++++++++------ app/api/routes.py | 2 ++ 4 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 app/api/prompt/linkedin.py diff --git a/app/api/prompt/__init__.py b/app/api/prompt/__init__.py index 227ad88..5868b5e 100644 --- a/app/api/prompt/__init__.py +++ b/app/api/prompt/__init__.py @@ -1,3 +1,4 @@ """LLM 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..854a61b --- /dev/null +++ b/app/api/prompt/linkedin.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends + +from app.utils.api_key_auth import get_api_key +from app.utils.make_meta import make_meta + +router = APIRouter() + + +@router.post("/prompt/linkedin") +def linkedin_prompt_success(api_key: str = Depends(get_api_key)) -> dict: + """POST /prompt/linkedin: Success stub endpoint.""" + return { + "meta": make_meta("success", "LinkedIn prompt endpoint working"), + "message": "LinkedIn prompt endpoint is live.", + } 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) From b340236ae79fd9f4b8963a6c603bdaab18ecb521 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Mon, 13 Apr 2026 13:26:42 +0100 Subject: [PATCH 4/4] Rename llm to prompt, add LinkedIn cache Migrate storage and API from 'llm' to 'prompt': update SQL create table, change all queries/inserts to use prompt, remove tsvector/search_vector usage, and update logging/meta messages. Add drop_llm_table.sql to clean up old table. Enhance POST /prompt/linkedin to check the prompt table for a cached completion by linkedinUrl (returns cached result or a warning if not found). Update prospects endpoint to fetch related prompt_records instead of llm_records and adjust returned fields. Minor docstring and metadata text updates. --- app/api/prompt/__init__.py | 2 +- app/api/prompt/linkedin.py | 66 ++++++++++++++++++++++++--- app/api/prompt/prompt.py | 25 +++++----- app/api/prompt/sql/create_table.sql | 2 +- app/api/prompt/sql/drop_llm_table.sql | 1 + app/api/prospects/prospects.py | 23 +++++----- 6 files changed, 84 insertions(+), 35 deletions(-) create mode 100644 app/api/prompt/sql/drop_llm_table.sql diff --git a/app/api/prompt/__init__.py b/app/api/prompt/__init__.py index 5868b5e..7ba875d 100644 --- a/app/api/prompt/__init__.py +++ b/app/api/prompt/__init__.py @@ -1,4 +1,4 @@ -"""LLM Routes""" +"""Prompt Routes""" 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 index 854a61b..a054b13 100644 --- a/app/api/prompt/linkedin.py +++ b/app/api/prompt/linkedin.py @@ -1,15 +1,67 @@ -from fastapi import APIRouter, Depends +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(api_key: str = Depends(get_api_key)) -> dict: - """POST /prompt/linkedin: Success stub endpoint.""" - return { - "meta": make_meta("success", "LinkedIn prompt endpoint working"), - "message": "LinkedIn prompt endpoint is live.", - } +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/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/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}")