diff --git a/README.md b/README.md index bf3ed91..c9b477f 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,9 @@ Live at https://tenantfirstaid.com/ ### Prerequisites - [uv](https://docs.astral.sh/uv/getting-started/installation/) - - [docker](https://www.docker.com/) 1. copy `backend/.env.example` to a new file named `.env` in the same directory and populate it with your `OPENAI_API_KEY`. You can set an invalid key, in which case the bot will return error messages. This may still be useful for developing other features. 1. `cd backend` -1. `docker-compose up` (use `-d` if you want to run this in the background, otherwise open a new terminal) 1. `uv sync` 1. If you have not uploaded the Oregon Housing Law documents to a vector store in OpenAI, run `uv run scripts/create_vector_store.py` and follow the instructions to add the vector store ID to your `.env`. 1. `uv run python -m tenantfirstaid.app` diff --git a/backend/.env.example b/backend/.env.example index 63656c7..4b50457 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,14 +1,8 @@ # Specify a different model -MODEL_NAME=gpt-2.5-flash +MODEL_NAME=gpt-2.5-pro -# Vector store ID for OpenAI (use the create_vector_store script to create one) -VECTOR_STORE_ID=my_vector_store_id - -# DB Info -DB_HOST=127.0.0.1 -DB_PORT=6379 -DB_USE_SSL=false +# Specify model for eval user +USER_MODEL_NAME=gemini-2.0-flash-lite -# Not used for local dev -#DB_USERNAME=default -#DB_PASSWORD=password +# Vector store ID for OpenAI (use the create_vector_store script to create one) +GEMINI_RAG_CORPUS=my_vector_store_id \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 794305a..9c66e4e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,6 @@ version = "0.2.0" requires-python = ">=3.12" dependencies = [ "flask>=3.1.1", - "valkey>=6.1.0", "gunicorn>=23.0.0", "google-auth>=2.40.3", "google-genai>=1.28.0", diff --git a/backend/tenantfirstaid/app.py b/backend/tenantfirstaid/app.py index 425afc0..0c69d02 100644 --- a/backend/tenantfirstaid/app.py +++ b/backend/tenantfirstaid/app.py @@ -1,8 +1,5 @@ from pathlib import Path -from flask import Flask, jsonify, session -import os -import secrets - +from flask import Flask if Path(".env").exists(): from dotenv import load_dotenv @@ -11,46 +8,10 @@ from .chat import ChatView -from .session import InitSessionView, TenantSession -from .citations import get_citation - app = Flask(__name__) -# Configure Flask sessions -app.secret_key = os.getenv("FLASK_SECRET_KEY", secrets.token_hex(32)) -app.config["SESSION_COOKIE_HTTPONLY"] = True -app.config["SESSION_COOKIE_SECURE"] = os.getenv("ENV", "dev") == "prod" -app.config["SESSION_COOKIE_SAMESITE"] = "Lax" - - -tenant_session = TenantSession() - - -@app.get("/api/history") -def history(): - saved_session = tenant_session.get() - return jsonify(saved_session["messages"]) - - -@app.post("/api/clear-session") -def clear_session(): - session.clear() - return jsonify({"success": True}) - - -app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], -) - -app.add_url_rule( - "/api/query", view_func=ChatView.as_view("chat", tenant_session), methods=["POST"] -) -app.add_url_rule( - "/api/citation", endpoint="citation", view_func=get_citation, methods=["GET"] -) +app.add_url_rule("/api/query", view_func=ChatView.as_view("chat"), methods=["POST"]) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5001) diff --git a/backend/tenantfirstaid/chat.py b/backend/tenantfirstaid/chat.py index 104615b..5e5f6dd 100644 --- a/backend/tenantfirstaid/chat.py +++ b/backend/tenantfirstaid/chat.py @@ -74,6 +74,7 @@ def generate_gemini_chat_response( instructions=None, model_name=MODEL, ): + print(f"Generating response for messages: {messages}") instructions = ( instructions if instructions @@ -112,28 +113,26 @@ def generate_gemini_chat_response( generation_config=GenerationConfig(temperature=0.2), tools=[rag_retrieval_tool] if use_tools else None, ) + print(f"Response: {response}") return response class ChatView(View): - def __init__(self, tenant_session) -> None: - self.tenant_session = tenant_session + def __init__(self) -> None: self.chat_manager = ChatManager() def dispatch_request(self, *args, **kwargs) -> Response: data = request.json - user_msg = data["message"] - - current_session = self.tenant_session.get() - current_session["messages"].append(dict(role="user", content=user_msg)) + messages = data["messages"] + print(f"Received messages: {messages}") def generate(): # Use the new Responses API with streaming response_stream = self.chat_manager.generate_gemini_chat_response( - current_session["messages"], - current_session["city"], - current_session["state"], + messages, + data["city"], + data["state"], stream=True, ) @@ -142,15 +141,6 @@ def generate(): assistant_chunks.append(event.candidates[0].content.parts[0].text) yield event.candidates[0].content.parts[0].text - # Join the complete response - assistant_msg = "".join(assistant_chunks) - - current_session["messages"].append( - {"role": "model", "content": assistant_msg} - ) - - self.tenant_session.set(current_session) - return Response( stream_with_context(generate()), mimetype="text/plain", diff --git a/backend/tenantfirstaid/citations.py b/backend/tenantfirstaid/citations.py deleted file mode 100644 index fef4184..0000000 --- a/backend/tenantfirstaid/citations.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import pathlib -from flask import request, jsonify, abort - -DATA_PATH = pathlib.Path(__file__).with_name("sections.json") -with DATA_PATH.open(encoding="utf-8") as f: - SECTIONS = json.load(f) - - -def get_citation(): - """ - GET /api/citation?section=90.101 - → 200 {"section": "90.101", "text": "..."} - → 400 if param missing, 404 if unknown - """ - section = request.args.get("section") - if not section: - abort(400, description="missing query param ?section=") - - body = SECTIONS.get(section) - if body is None: - abort(404, description=f"section {section} not found") - - return jsonify({"section": section, "text": body}) diff --git a/backend/tenantfirstaid/session.py b/backend/tenantfirstaid/session.py deleted file mode 100644 index 555f9e3..0000000 --- a/backend/tenantfirstaid/session.py +++ /dev/null @@ -1,97 +0,0 @@ -import os -import uuid -from flask import Response, after_this_request, request, session -from flask.views import View -from typing import TypedDict -from valkey import Valkey -import simplejson as json -from typing import Any, Dict, Optional, Literal - - -class TenantSessionData(TypedDict): - city: str - state: str - messages: list # List of messages with role and content - - -NEW_SESSION_DATA = TenantSessionData(city="null", state="or", messages=[]) - - -# The class to manage tenant sessions using Valkey and Flask sessions -class TenantSession: - def __init__(self) -> None: - print( - "Connecting to Valkey:", - { - "host": os.getenv("DB_HOST"), - "port": os.getenv("DB_PORT"), - "ssl": os.getenv("DB_USE_SSL"), - }, - ) - try: - self.db_con = Valkey( - host=os.getenv("DB_HOST", "127.0.0.1"), - port=os.getenv("DB_PORT", 6379), - password=os.getenv("DB_PASSWORD"), - ssl=False if os.getenv("DB_USE_SSL") == "false" else True, - ) - self.db_con.ping() - - except Exception as e: - print(e) - - # Retrieves the session ID from Flask session or creates a new one - def get_flask_session_id(self) -> str: - session_id: str | None = session.get("session_id") - if not session_id: - session_id = str(uuid.uuid4()) - session["session_id"] = session_id - - @after_this_request - def save_session(response: Response) -> Response: - session.modified = True - return response - - return session_id - - def get(self) -> TenantSessionData: - session_id = self.get_flask_session_id() - - saved_session: Optional[str] = self.db_con.get(session_id) # type: ignore # known issue https://github.com/valkey-io/valkey-py/issues/164 - if not saved_session: - return self.getNewSessionData() - - obj = json.loads(s=saved_session) - return TenantSessionData( - city=obj["city"], state=obj["state"], messages=obj["messages"] - ) - - def set(self, value: TenantSessionData) -> None: - session_id = self.get_flask_session_id() - self.db_con.set(session_id, json.dumps(value)) - - def getNewSessionData(self) -> TenantSessionData: - return TenantSessionData(**NEW_SESSION_DATA.copy()) - - -# The Flask view to initialize a session -class InitSessionView(View): - def __init__(self, tenant_session: TenantSession): - self.tenant_session = tenant_session - - def dispatch_request(self, *args, **kwargs) -> Response: - data: Dict[str, Any] = request.json - session_id: Optional[str] = self.tenant_session.get_flask_session_id() - - city: str | Literal["null"] = data["city"] or "null" - state: str = data["state"] - - # Initialize the session with city and state - initial_data = TenantSessionData(city=city, state=state, messages=[]) - self.tenant_session.set(initial_data) - - return Response( - status=200, - response=json.dumps({"session_id": session_id}), - mimetype="application/json", - ) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 766cd43..dd8b0fb 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -7,12 +7,10 @@ ) from flask import Flask from tenantfirstaid.chat import ChatView -from tenantfirstaid.session import TenantSession, TenantSessionData, InitSessionView from vertexai.generative_models import ( GenerativeModel, Tool, ) -from typing import Dict @pytest.fixture @@ -57,44 +55,15 @@ def test_default_instructions_contains_citation_links(): @pytest.fixture -def mock_valkey_ping_nop(mocker, monkeypatch): - """Mock the Valkey class with the db_con.ping() method.""" - - monkeypatch.setenv("DB_HOST", "8.8.8.8") - monkeypatch.setenv("DB_PORT", "8888") - monkeypatch.setenv("DB_PASSWORD", "test_password") - monkeypatch.setenv("DB_USE_SSL", "false") - - mock_valkey_client = mocker.Mock() - mocker.patch("tenantfirstaid.session.Valkey", return_value=mock_valkey_client) - mock_valkey_client.ping = () - return mock_valkey_client - - -@pytest.fixture -def mock_valkey(mock_valkey_ping_nop, mocker): - _data: Dict[str, str] = {} - - mock_valkey_ping_nop.set = mocker.Mock( - side_effect=lambda key, value: _data.update({key: value}) - ) - - mock_valkey_ping_nop.get = mocker.Mock(side_effect=lambda key: _data[key]) - - return mock_valkey_ping_nop - - -@pytest.fixture -def app(mock_valkey): +def app(): app = Flask(__name__) app.testing = True # propagate exceptions to the test client - app.secret_key = "test_secret_key" # Set a secret key for session management return app def test_chat_view_dispatch_request_streams_response( - app, mocker, mock_vertexai_generative_model, mock_valkey + app, mocker, mock_vertexai_generative_model ): """Test that sends a message to the API, mocks vertexai response, and validates output.""" @@ -116,32 +85,12 @@ def test_chat_view_dispatch_request_streams_response( mocker.patch("tenantfirstaid.chat.rag.Retrieval", return_value=mock_retrieval) mocker.patch("tenantfirstaid.chat.Tool.from_retrieval", return_value=mock_rag_tool) - tenant_session = TenantSession() - - app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], - ) - app.add_url_rule( "/api/query", - view_func=ChatView.as_view("chat", tenant_session), + view_func=ChatView.as_view("chat"), methods=["POST"], ) - test_data_obj = TenantSessionData( - city="Portland", - state="or", - messages=[], - ) - - # Initialize the session - with app.test_request_context("/api/init", method="POST", json=test_data_obj): - init_response = app.full_dispatch_request() - assert init_response.status_code == 200 - session_id = init_response.json["session_id"] - # Mock the GenerativeModel's generate_content method mock_response_text = "This is a mocked response about tenant rights in Oregon. You should contact ORS 90.427 for more information." @@ -160,10 +109,14 @@ def test_chat_view_dispatch_request_streams_response( test_message = "What are my rights as a tenant in Portland?" with app.test_request_context( - "/api/query", method="POST", json={"message": test_message} + "/api/query", + method="POST", + json={ + "messages": [{"role": "user", "content": test_message}], + "city": "Portland", + "state": "or", + }, ) as chat_ctx: - chat_ctx.session["session_id"] = session_id - # Execute the request chat_response = chat_ctx.app.full_dispatch_request() @@ -190,11 +143,3 @@ def test_chat_view_dispatch_request_streams_response( # Check that streaming was enabled assert call_args[1]["stream"] is True - - # Verify the session was updated with both user and assistant messages - updated_session = tenant_session.get() - assert len(updated_session["messages"]) == 2 - assert updated_session["messages"][0]["role"] == "user" - assert updated_session["messages"][0]["content"] == test_message - assert updated_session["messages"][1]["role"] == "model" - assert updated_session["messages"][1]["content"] == mock_response_text diff --git a/backend/tests/test_citations.py b/backend/tests/test_citations.py deleted file mode 100644 index ba31334..0000000 --- a/backend/tests/test_citations.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from flask import Flask -from tenantfirstaid.citations import get_citation, SECTIONS - - -@pytest.fixture -def app(): - app = Flask(__name__) - app.add_url_rule("/api/citation", view_func=get_citation, methods=["GET"]) - return app - - -@pytest.fixture -def client(app): - return app.test_client() - - -def test_get_citation_success(client): - # Use a valid section from SECTIONS - section = next(iter(SECTIONS)) - response = client.get(f"/api/citation?section={section}") - assert response.status_code == 200 - data = response.get_json() - assert data["section"] == section - assert data["text"] == SECTIONS[section] - - -def test_get_citation_missing_param(client): - response = client.get("/api/citation") - assert response.status_code == 400 - assert b"missing query param" in response.data - - -def test_get_citation_unknown_section(client): - response = client.get("/api/citation?section=notarealsection") - assert response.status_code == 404 - assert b"not found" in response.data - - -def test_get_citation_empty_section_param(client): - response = client.get("/api/citation?section=") - assert response.status_code == 400 - assert b"missing query param" in response.data diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py deleted file mode 100644 index 0d46322..0000000 --- a/backend/tests/test_session.py +++ /dev/null @@ -1,164 +0,0 @@ -import pytest -from flask import Flask -from tenantfirstaid.session import TenantSession, InitSessionView, TenantSessionData -from typing import Dict - - -@pytest.fixture -def mock_valkey_ping_nop(mocker): - """Mock the Valkey class with the db_con.ping() method.""" - mock_valkey_client = mocker.Mock() - mocker.patch("tenantfirstaid.session.Valkey", return_value=mock_valkey_client) - mock_valkey_client.ping = mocker.Mock() - return mock_valkey_client - - -@pytest.fixture -def mock_valkey(mock_valkey_ping_nop, mocker): - _data: Dict[str, str] = {} - - mock_valkey_ping_nop.set = mocker.Mock( - side_effect=lambda key, value: _data.update({key: value}) - ) - - mock_valkey_ping_nop.get = mocker.Mock(side_effect=lambda key: _data[key]) - - return mock_valkey_ping_nop - - -@pytest.fixture -def mock_environ(monkeypatch): - monkeypatch.setenv("DB_HOST", "8.8.8.8") - monkeypatch.setenv("DB_PORT", "8888") - monkeypatch.setenv("DB_PASSWORD", "test_password") - monkeypatch.setenv("DB_USE_SSL", "false") - - -def test_session_init_success(mocker, mock_environ): - test_data = { - "city": "Test City", - "state": "Test State", - } - - mock_valkey_client = mocker.Mock() - mocker.patch("tenantfirstaid.session.Valkey", return_value=mock_valkey_client) - mock_valkey_client.ping = mocker.Mock() - - tenant_session = TenantSession() - app = Flask(__name__) - app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], - ) - app.secret_key = "test_secret_key" # Set a secret key for session management - - with app.test_request_context("/api/init", method="POST", json=test_data) as reqctx: - assert ( - reqctx.session.get("session_id") is None - ) # Ensure session_id is NOT set in the request context (before dispatch) - response = app.full_dispatch_request() - assert response.status_code == 200 # Ensure the response is successful - assert ( - reqctx.session.get("session_id") is not None - ) # Ensure session_id is set in the request context - - -def test_session_init_ping_exception(mocker, capsys): - # Patch Valkey so that ping raises an exception - mock_client = mocker.Mock() - mock_client.ping = mocker.Mock(side_effect=Exception("Ping failed")) - mocker.patch("tenantfirstaid.session.Valkey", return_value=mock_client) - # Should not raise, but print the exception - _obj = TenantSession() - captured = capsys.readouterr() - assert "Ping failed" in captured.out - - -def test_session_get_unknown_session_id(mocker, mock_environ): - test_data = {"city": "Test City", "state": "Test State", "messages": []} - - mock_valkey_client = mocker.Mock() - mocker.patch("tenantfirstaid.session.Valkey", return_value=mock_valkey_client) - mock_valkey_client.ping = mocker.Mock() - mock_valkey_client.get = mocker.Mock(return_value=None) # Simulate unknown session - - tenant_session = TenantSession() - app = Flask(__name__) - app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], - ) - app.secret_key = "test_secret_key" # Set a secret key for session management - - with app.test_request_context("/api/init", method="POST", json=test_data) as reqctx: - assert ( - reqctx.session.get("session_id") is None - ) # Ensure session_id is NOT set in the request context (before dispatch) - assert tenant_session.get() == { - "city": "null", - "state": "or", - "messages": [], - } - - -def test_session_set_and_get(mocker, mock_environ, mock_valkey): - test_data_obj = TenantSessionData( - city="Test City", - state="Test State", - messages=[], - ) - - tenant_session = TenantSession() - app = Flask(__name__) - app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], - ) - app.secret_key = "test_secret_key" # Set a secret key for session management - - with app.test_request_context("/api/init", method="POST", json=test_data_obj): - response = app.full_dispatch_request() - assert response.status_code == 200 # Ensure the response is successful - session_id = response.json["session_id"] - assert session_id is not None # Ensure session_id is set - assert isinstance(session_id, str) # Ensure session_id is a string - - tenant_session.set(test_data_obj) - assert tenant_session.get() == test_data_obj - - -def test_session_set_some_and_get_none(mocker, mock_environ, mock_valkey): - test_data_obj = TenantSessionData( - city="Test City", - state="Test State", - messages=[], - ) - - tenant_session = TenantSession() - app = Flask(__name__) - app.add_url_rule( - "/api/init", - view_func=InitSessionView.as_view("init", tenant_session), - methods=["POST"], - ) - app.secret_key = "test_secret_key" # Set a secret key for session management - - # Simulate no data for the session (i.e. network error or similar) - mock_valkey.get.side_effect = lambda key: None - - with app.test_request_context("/api/init", method="POST", json=test_data_obj): - response = app.full_dispatch_request() - assert response.status_code == 200 # Ensure the response is successful - session_id = response.json["session_id"] - assert session_id is not None # Ensure session_id is set - assert isinstance(session_id, str) # Ensure session_id is a string - - tenant_session.set(test_data_obj) - assert tenant_session.get() == { - "city": "null", - "state": "or", - "messages": [], - } diff --git a/backend/uv.lock b/backend/uv.lock index ecf33ae..daa27b9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -1378,7 +1378,6 @@ dependencies = [ { name = "pandas" }, { name = "python-dotenv" }, { name = "simplejson" }, - { name = "valkey" }, { name = "vertexai" }, ] @@ -1413,7 +1412,6 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.0" }, { name = "python-dotenv" }, { name = "simplejson" }, - { name = "valkey", specifier = ">=6.1.0" }, { name = "vertexai", specifier = ">=1.43.0" }, ] @@ -1592,15 +1590,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] -[[package]] -name = "valkey" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/d4/04b6a234e584e21ccb63b895ca9dfb4e759e4c139c1ab3f9484982ee6491/valkey-6.1.0.tar.gz", hash = "sha256:a652df15ed89c41935ffae6dfd09c56f4a9ab80b592e5ed9204d538e2ddad6d3", size = 4600944, upload-time = "2025-02-11T17:20:46.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/b0/c4d47032bbda89cff7af99c0b096db9b9453b9f0c1e24cf027aa616be389/valkey-6.1.0-py3-none-any.whl", hash = "sha256:cfe769edae894f74ac946eff1e93f7d7f466032c3030ba7e9d089a742459ac9c", size = 259302, upload-time = "2025-02-11T17:20:42.96Z" }, -] - [[package]] name = "vertexai" version = "1.43.0" diff --git a/frontend/src/About.tsx b/frontend/src/About.tsx index bf0dc39..2aae57c 100644 --- a/frontend/src/About.tsx +++ b/frontend/src/About.tsx @@ -49,6 +49,11 @@ export default function About() { Aid will provide helpful information or direct you to relevant resources.

+

Data Usage

+

+ Tenant First Aid does not store any personal data. All interactions + are processed in real-time and not saved for future use. +

Quick Facts: