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: