diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 79640b44..db8e1d5d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -64,6 +64,16 @@ uv run python scripts/run_langsmith_evaluation.py --num-samples 20 ## Local `./frontend` workflow +Frontend TypeScript types in `src/types/` are auto-generated from the backend Python models and are not checked into source control. You must generate them before building or type-checking: + +```bash +npm run generate-types +# or equivalently: +make generate-types # (run from the backend/ directory) +``` + +This requires `uv` to be installed (see backend setup). + 1. Format, lint and type‑check your changes: ```bash @@ -71,7 +81,7 @@ uv run python scripts/run_langsmith_evaluation.py --num-samples 20 npx run format ``` -2. Build frontend code +2. Build frontend code (automatically generates types first) ```bash npm run build ``` diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6f666151..103248fa 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -103,9 +103,27 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - name: Install uv + uses: astral-sh/setup-uv@v7.3.0 + with: + enable-cache: true + cache-dependency-glob: "backend/uv.lock" + version: "0.10.0" + + - name: Set up Python + run: uv python install 3.13 + working-directory: backend + + - name: Sync backend dependencies + run: uv sync + working-directory: backend + - name: Install dependencies run: npm ci + - name: Generate types + run: npm run generate-types + - name: Run linting checks run: npm run lint diff --git a/.gitignore b/.gitignore index e936210c..9bfe7f22 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ dist/ .vite/ *.log +# Auto-generated frontend types (run `make generate-types` to regenerate) +frontend/src/types/ + # Coverage reports .coverage htmlcov/ diff --git a/Architecture.md b/Architecture.md index ec20a7ce..a6002223 100644 --- a/Architecture.md +++ b/Architecture.md @@ -76,6 +76,7 @@ backend/ │ ├── vertex_ai_list_datastores.py # Utility to get Google Vertex AI Datastore IDs │ ├── create_vector_store.py # RAG corpus setup │ ├── convert_csv_to_jsonl.py # Data conversion utilities +│ ├── generate_types.py # Models exported to the frontend; pydantic2ts generates frontend/src/types/models.ts from this (run via `make generate-types` or `npm run generate-types`) │ └── documents/ # Source legal documents │ └── or/ # Oregon state laws │ ├── OAR54.txt # Oregon Administrative Rules @@ -335,10 +336,7 @@ async function streamText({ ]); try { - const reader = await addMessage({ - city: housingLocation?.city, - state: housingLocation?.state || "", - }); + const reader = await addMessage(housingLocation); if (!reader) { console.error("Stream reader is unavailable"); const nullReaderError: TUiMessage = { @@ -465,8 +463,8 @@ frontend/ │ │ ├── useMessages.tsx # Message handling logic │ │ ├── useHousingContext.tsx # Custom hook for housing context │ │ └── useLetterContent.tsx # State management for letter generation -│ ├── types/ -│ │ └── MessageTypes.ts # TypeScript types mirroring backend schema (TResponseChunk, etc.) +│ ├── types/ # Auto-generated TypeScript types (gitignored) — do not edit manually, re-run `make generate-types` or `npm run generate-types` +│ │ └── models.ts # All exported types: ResponseChunk, Location, OregonCity, UsaState, chunk interfaces │ ├── layouts/ # Layouts │ │ └── PageLayout.tsx # Layout for pages │ ├── pages/ @@ -491,7 +489,7 @@ frontend/ │ │ │ │ ├── LetterDisclaimer.tsx # Disclaimer for Letter page │ │ │ │ └── LetterGenerationDialog.tsx # Letter page dialog │ │ │ └── utils/ -│ │ │ └── letterHelper.tsx # Letter generation functionality +│ │ │ └── letterHelper.ts # Letter generation functionality │ │ └── LoadingPage.tsx # Loading component for routes │ ├── shared/ # Shared components and utils │ │ ├── components/ @@ -511,7 +509,8 @@ frontend/ │ │ │ └── constants.ts # File of constants │ │ └── utils/ │ │ ├── scrolling.ts # Helper function for window scrolling -│ │ └── dompurify.ts # Helper function for sanitizing text +│ │ ├── dompurify.ts # Helper function for sanitizing text +│ │ └── formatLocation.ts # Formats OregonCity/UsaState into a display string (e.g. "Portland, OR") │ └── tests/ # Testing suite │ │ ├── components/ # Component testing │ │ │ ├── About.test.tsx # About component testing @@ -536,7 +535,8 @@ frontend/ │ │ ├── formHelper.test.ts # formHelper testing │ │ ├── letterHelper.test.ts # letterHelper testing │ │ ├── sanitizeText.test.ts # sanitizeText testing -│ │ └── streamHelper.test.ts # streamHelper testing +│ │ ├── streamHelper.test.ts # streamHelper testing +│ │ └── formatLocation.test.ts # formatLocation testing ├── public/ │ └── favicon.svg # Site favicon ├── package.json # Dependencies and scripts diff --git a/README.md b/README.md index c831e70f..bf24abe5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Live at https://tenantfirstaid.com/ 1. Open a new terminal / tab 1. `cd ../frontend` 1. `npm install` +1. `npm run generate-types` 1. `npm run dev` 1. Go to http://localhost:5173 1. Start chatting diff --git a/backend/Makefile b/backend/Makefile index 0404bdf8..e3a473b2 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,7 +1,8 @@ PYTHON := uv PIP := $(PYTHON) pip +FRONTEND_DIR := ../frontend -.PHONY: all install test clean check +.PHONY: all install test clean check generate-types all: check @@ -38,6 +39,10 @@ typecheck-pyrefly: uv.lock test: uv.lock uv run pytest -v -s $(TEST_OPTIONS) +generate-types: uv.lock + mkdir -p $(FRONTEND_DIR)/src/types + $(PYTHON) run pydantic2ts --module scripts/generate_types.py --output $(FRONTEND_DIR)/src/types/models.ts --json2ts-cmd "npx --prefix $(FRONTEND_DIR) json2ts" + clean: find . -type d -name '__pycache__' -exec rm -r {} + rm -rf dist build *.egg-info diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6111a778..07cba4d5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "types-flask-cors>=6.0.0.20250809", "google-cloud-discoveryengine>=0.15.0", "langchain-google-vertexai>=3.2.2", + "pydantic-to-typescript>=2.0.0", "pandas>=3.0.1", ] diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py new file mode 100644 index 00000000..6b23af86 --- /dev/null +++ b/backend/scripts/generate_types.py @@ -0,0 +1,14 @@ +"""Models exported to the frontend as TypeScript types. + +Used by pydantic2ts via `make generate-types`. All BaseModel subclasses +visible in this module's namespace are included in the generated output. +""" + +from pydantic import RootModel + +from tenantfirstaid.location import Location # noqa: F401 +from tenantfirstaid.schema import LetterChunk, ReasoningChunk, TextChunk + + +class ResponseChunk(RootModel[TextChunk | ReasoningChunk | LetterChunk]): + """Union of all possible streaming response chunk types.""" diff --git a/backend/tenantfirstaid/location.py b/backend/tenantfirstaid/location.py index adbf21a9..02d0ab7c 100644 --- a/backend/tenantfirstaid/location.py +++ b/backend/tenantfirstaid/location.py @@ -7,6 +7,7 @@ from typing import Optional from langchain.agents import AgentState +from pydantic import BaseModel def city_or_state_input_sanitizer(location: Optional[str], max_len: int = 9) -> str: @@ -62,6 +63,16 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState": return state +class Location(BaseModel): + """City and state as sent by the frontend. + + state=None is treated as UsaState.OTHER by the backend. + """ + + city: OregonCity | None = None + state: UsaState | None = None + + class TFAAgentStateSchema(AgentState): state: UsaState city: Optional[OregonCity] diff --git a/backend/uv.lock b/backend/uv.lock index 4be1ffa6..1f5aa136 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2326,6 +2326,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pydantic-to-typescript" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/ac/8dc13be6720267b5edffb3b0d8c1abdcccc5743d7bda4257ab0a5dccc7b9/pydantic_to_typescript-2.0.0.tar.gz", hash = "sha256:06e92e8d10759ffebe3abdd4de14015bbf80809fa9960273be6041d8509662be", size = 35484, upload-time = "2024-11-22T03:33:59.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/9c/f51312f3063a8b6e1e09e6527a81d3b1828ad5fc8a65c14376f85cf65502/pydantic_to_typescript-2.0.0-py3-none-any.whl", hash = "sha256:5bc5e1a940c1a39e4e3a6124bd3bda1b0fc6df85c673a4781b94a1f4150cae15", size = 9693, upload-time = "2024-11-22T03:33:57.199Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2872,6 +2884,7 @@ dev = [ { name = "openevals" }, { name = "pandas" }, { name = "polars" }, + { name = "pydantic-to-typescript" }, { name = "pyrefly" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2911,6 +2924,7 @@ dev = [ { name = "openevals", specifier = ">=0.1.2" }, { name = "pandas", specifier = ">=3.0.1" }, { name = "polars", specifier = ">=1.35.2" }, + { name = "pydantic-to-typescript", specifier = ">=2.0.0" }, { name = "pyrefly", specifier = ">=0.21.0" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 393a3650..13a2213e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier/flat"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["dist", "src/types"] }, { extends: [ js.configs.recommended, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74fddfe4..9a257372 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "jsdom": "^27.0.1", + "json-schema-to-typescript": "^15.0.4", "prettier": "^3.6.2", "typescript": "~5.7.2", "typescript-eslint": "^8.26.1", @@ -53,6 +54,24 @@ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", @@ -131,7 +150,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -476,7 +494,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -518,7 +535,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -1147,6 +1163,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@langchain/core": { "version": "1.1.24", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.24.tgz", @@ -1859,7 +1882,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1956,6 +1980,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1975,7 +2006,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1985,7 +2015,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2051,7 +2080,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2433,7 +2461,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2481,6 +2508,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -2625,7 +2653,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2972,7 +2999,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dompurify": { "version": "3.3.0", @@ -3084,7 +3112,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3787,7 +3814,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -3840,6 +3866,30 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4184,6 +4234,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4214,6 +4271,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4932,6 +4990,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5151,7 +5219,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true, "engines": { "node": ">=12" }, @@ -5215,6 +5282,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5229,6 +5297,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -5260,7 +5329,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5270,7 +5338,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5282,7 +5349,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -5802,7 +5870,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6005,7 +6072,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6080,7 +6146,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", diff --git a/frontend/package.json b/frontend/package.json index ff802c64..f82717b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "generate-types": "cd ../backend && make generate-types", + "build": "npm run generate-types && tsc -b && vite build", "lint": "eslint .", "format": "prettier --write .", "preview": "vite preview", @@ -39,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "jsdom": "^27.0.1", + "json-schema-to-typescript": "^15.0.4", "prettier": "^3.6.2", "typescript": "~5.7.2", "typescript-eslint": "^8.26.1", diff --git a/frontend/src/Letter.tsx b/frontend/src/Letter.tsx index 0b8fe8bf..5c01eb62 100644 --- a/frontend/src/Letter.tsx +++ b/frontend/src/Letter.tsx @@ -1,5 +1,5 @@ import { HumanMessage } from "@langchain/core/messages"; -import type { TUiMessage } from "./hooks/useMessages"; +import type { UiMessage } from "./hooks/useMessages"; import MessageWindow from "./pages/Chat/components/MessageWindow"; import useMessages from "./hooks/useMessages"; import { useEffect, useRef, useState } from "react"; @@ -12,7 +12,7 @@ import LetterDisclaimer from "./pages/Letter/components/LetterDisclaimer"; import MessageContainer from "./shared/components/MessageContainer"; import useHousingContext from "./hooks/useHousingContext"; import { buildChatUserMessage } from "./pages/Chat/utils/formHelper"; -import { ILocation } from "./contexts/HousingContext"; +import type { Location } from "./types/models"; import FeatureSnippet from "./shared/components/FeatureSnippet"; import clsx from "clsx"; @@ -22,7 +22,7 @@ export default function Letter() { const { letterContent } = useLetterContent(messages); const { org, loc } = useParams(); const [startStreaming, setStartStreaming] = useState(false); - const streamLocationRef = useRef(null); + const streamLocationRef = useRef(null); const [isGenerating, setIsGenerating] = useState(true); const dialogRef = useRef(null); const timerRef = useRef | null>(null); @@ -78,7 +78,7 @@ export default function Letter() { "Unable to generate letter. Please try again or refresh the page."; const text = streamDone ? INITIAL_INSTRUCTION : ERROR_MESSAGE; - const uiMessage: TUiMessage = { + const uiMessage: UiMessage = { type: "ui", text, id: Date.now().toString(), diff --git a/frontend/src/contexts/HousingContext.tsx b/frontend/src/contexts/HousingContext.tsx index 1a774ecb..47d7c6a7 100644 --- a/frontend/src/contexts/HousingContext.tsx +++ b/frontend/src/contexts/HousingContext.tsx @@ -1,56 +1,57 @@ import { createContext, useCallback, useMemo, useState } from "react"; import DOMPurify, { SANITIZE_USER_SETTINGS } from "../shared/utils/dompurify"; +import type { Location } from "../types/models"; +import type { + CitySelectKey, + HousingType, + TenantTopic, +} from "../shared/constants/constants"; -export interface ILocation { - city: string | null; - state: string | null; -} - -export interface IHousingContextType { - housingLocation: ILocation; - city: string | null; - housingType: string | null; - tenantTopic: string | null; +export interface HousingContextType { + housingLocation: Location; + city: CitySelectKey | null; + housingType: HousingType | null; + tenantTopic: TenantTopic | null; issueDescription: string; - handleHousingLocation: ({ city, state }: ILocation) => void; - handleCityChange: (option: string | null) => void; - handleHousingChange: (option: string | null) => void; - handleTenantTopic: (option: string | null) => void; + handleHousingLocation: ({ city, state }: Location) => void; + handleCityChange: (option: CitySelectKey | null) => void; + handleHousingChange: (option: HousingType | null) => void; + handleTenantTopic: (option: TenantTopic | null) => void; handleIssueDescription: ( event: React.ChangeEvent, ) => void; handleFormReset: () => void; } -const HousingContext = createContext(null); +const HousingContext = createContext(null); interface Props { children: React.ReactNode; } export default function HousingContextProvider({ children }: Props) { - const [city, setCity] = useState(null); - const [housingLocation, setHousingLocation] = useState({ + const [city, setCity] = useState(null); + const [housingLocation, setHousingLocation] = useState({ city: null, state: null, }); - const [housingType, setHousingType] = useState(null); - const [tenantTopic, setTenantTopic] = useState(null); + const [housingType, setHousingType] = useState(null); + const [tenantTopic, setTenantTopic] = useState(null); const [issueDescription, setIssueDescription] = useState(""); - const handleHousingLocation = useCallback(({ city, state }: ILocation) => { + const handleHousingLocation = useCallback(({ city, state }: Location) => { setHousingLocation({ city, state }); }, []); - const handleCityChange = useCallback((option: string | null) => { + const handleCityChange = useCallback((option: CitySelectKey | null) => { setCity(option); }, []); - const handleHousingChange = useCallback((option: string | null) => { + const handleHousingChange = useCallback((option: HousingType | null) => { setHousingType(option); }, []); - const handleTenantTopic = useCallback((option: string | null) => { + const handleTenantTopic = useCallback((option: TenantTopic | null) => { setTenantTopic(option); }, []); diff --git a/frontend/src/hooks/useLetterContent.tsx b/frontend/src/hooks/useLetterContent.tsx index 35ce327c..d99b7907 100644 --- a/frontend/src/hooks/useLetterContent.tsx +++ b/frontend/src/hooks/useLetterContent.tsx @@ -1,19 +1,19 @@ import { useMemo } from "react"; -import { TChatMessage } from "./useMessages"; -import type { TResponseChunk } from "../types/MessageTypes"; +import { ChatMessage } from "./useMessages"; +import type { ResponseChunk } from "../types/models"; /** * Extracts generated letter content from chat messages by scanning all AI * messages and returning the last letter chunk found. */ -export function useLetterContent(messages: TChatMessage[]) { +export function useLetterContent(messages: ChatMessage[]) { const letterContent = useMemo(() => { const chunks = messages .filter((msg) => msg.type === "ai") .flatMap((msg) => msg.text.split("\n").filter(Boolean)) .flatMap((line) => { try { - return [JSON.parse(line) as TResponseChunk]; + return [JSON.parse(line) as ResponseChunk]; } catch { return []; // Not a JSON chunk — skip. } diff --git a/frontend/src/hooks/useMessages.tsx b/frontend/src/hooks/useMessages.tsx index dcef28e8..6853af1a 100644 --- a/frontend/src/hooks/useMessages.tsx +++ b/frontend/src/hooks/useMessages.tsx @@ -1,15 +1,16 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import type { AIMessage, HumanMessage } from "@langchain/core/messages"; +import type { Location } from "../types/models"; /** * Chat message Type aligned with LangChain's message types * to ensure consistency with backend. */ -export type TChatMessage = HumanMessage | AIMessage | TUiMessage; +export type ChatMessage = HumanMessage | AIMessage | UiMessage; /** UI-only message for display purposes; excluded from backend history. */ -export type TUiMessage = { +export type UiMessage = { type: "ui"; text: string; id: string; @@ -36,9 +37,8 @@ export function deserializeAiMessage(text: string): string { } async function addNewMessage( - messages: TChatMessage[], - city: string | null, - state: string, + messages: ChatMessage[], + { city, state }: Location, ) { const serializedMsg = messages.map((msg) => ({ role: msg.type, @@ -59,22 +59,16 @@ async function addNewMessage( * Provides message state, a setter, and a mutation for posting new messages. */ export default function useMessages() { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const addMessage = useMutation({ - mutationFn: async ({ - city, - state, - }: { - city: string | null; - state: string; - }) => { + mutationFn: async ({ city, state }: Location) => { // Exclude UI-only messages and empty placeholders from backend history. const filteredMessages = messages.filter( - (msg): msg is Exclude => + (msg): msg is Exclude => msg.type !== "ui" && msg.text.trim() !== "", ); - return await addNewMessage(filteredMessages, city, state); + return await addNewMessage(filteredMessages, { city, state }); }, }); diff --git a/frontend/src/pages/Chat/components/ExportMessagesButton.tsx b/frontend/src/pages/Chat/components/ExportMessagesButton.tsx index 61a94cdf..4032c794 100644 --- a/frontend/src/pages/Chat/components/ExportMessagesButton.tsx +++ b/frontend/src/pages/Chat/components/ExportMessagesButton.tsx @@ -1,8 +1,8 @@ -import { TChatMessage } from "../../../hooks/useMessages"; +import { ChatMessage } from "../../../hooks/useMessages"; import exportMessages from "../utils/exportHelper"; interface Props { - messages: TChatMessage[]; + messages: ChatMessage[]; } /** diff --git a/frontend/src/pages/Chat/components/FeedbackModal.tsx b/frontend/src/pages/Chat/components/FeedbackModal.tsx index ad811b1f..e1485ce3 100644 --- a/frontend/src/pages/Chat/components/FeedbackModal.tsx +++ b/frontend/src/pages/Chat/components/FeedbackModal.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import sendFeedback from "../utils/feedbackHelper"; -import { TChatMessage } from "../../../hooks/useMessages"; +import { ChatMessage } from "../../../hooks/useMessages"; interface Props { - messages: TChatMessage[]; + messages: ChatMessage[]; setOpenFeedback: React.Dispatch>; } diff --git a/frontend/src/pages/Chat/components/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index 44b75c8b..02df435d 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -1,5 +1,7 @@ -import { TChatMessage } from "../../../hooks/useMessages"; +import { ChatMessage } from "../../../hooks/useMessages"; import BeaverIcon from "../../../shared/components/BeaverIcon"; +import type { Location } from "../../../types/models"; +import { formatLocation } from "../../../shared/utils/formatLocation"; import { useEffect, useState } from "react"; import { buildChatUserMessage } from "../utils/formHelper"; import { streamText } from "../utils/streamHelper"; @@ -12,20 +14,24 @@ import { HOUSING_OPTIONS, LETTERABLE_TOPIC_OPTIONS, NONLETTERABLE_TOPIC_OPTIONS, + type CitySelectKey, + type HousingType, + type TenantTopic, } from "../../../shared/constants/constants"; import { scrollToTop } from "../../../shared/utils/scrolling"; import AutoExpandText from "./AutoExpandText"; import clsx from "clsx"; import { HumanMessage } from "@langchain/core/messages"; -const NONLETTERABLE_TOPICS = Object.keys(NONLETTERABLE_TOPIC_OPTIONS); +const NONLETTERABLE_TOPICS = Object.keys( + NONLETTERABLE_TOPIC_OPTIONS, +) as TenantTopic[]; interface Props { - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; - setMessages: React.Dispatch>; + addMessage: ( + args: Location, + ) => Promise | undefined>; + setMessages: React.Dispatch>; } /** @@ -46,19 +52,18 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { handleFormReset, } = useHousingContext(); const [initialUserMessage, setInitialUserMessage] = useState(""); - const locationString = city - ? city.charAt(0).toUpperCase() + city.slice(1) - : null; + const locationString = formatLocation( + housingLocation.city, + housingLocation.state, + ); const handleLocationChange = (key: string | null) => { - handleCityChange(key); + const typedKey = key as CitySelectKey | null; + const selected = typedKey !== null ? CITY_SELECT_OPTIONS[typedKey] : null; + handleCityChange(typedKey); handleHousingLocation({ - city: - CITY_SELECT_OPTIONS[key as keyof typeof CITY_SELECT_OPTIONS]?.city || - null, - state: - CITY_SELECT_OPTIONS[key as keyof typeof CITY_SELECT_OPTIONS]?.state || - null, + city: selected?.city ?? null, + state: selected?.state ?? null, }); }; @@ -142,14 +147,18 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { {city === "other" ? "Unfortunately, we can only answer questions related to tenant rights in Oregon at this time." - : `${locationString ? `I can help answer your questions about tenant rights in ${locationString}.` : ""}`} + : locationString + ? `I can help answer your questions about tenant rights in ${locationString}.` + : ""} + handleHousingChange(option as HousingType | null) + } > {HOUSING_OPTIONS.map((option) => ( {Object.entries(LETTERABLE_TOPIC_OPTIONS).map(([key, option]) => ( @@ -185,9 +196,10 @@ export default function InitializationForm({ addMessage, setMessages }: Props) {
Here are some examples of questions I can help with:
    - {ALL_TOPIC_OPTIONS[ - tenantTopic as keyof typeof ALL_TOPIC_OPTIONS - ]?.example.map((question, index) => ( + {(tenantTopic + ? ALL_TOPIC_OPTIONS[tenantTopic] + : null + )?.example.map((question, index) => (
  • {question.split(/(_)/).map((part, i) => { if (!part.startsWith("_")) return part; diff --git a/frontend/src/pages/Chat/components/InputField.tsx b/frontend/src/pages/Chat/components/InputField.tsx index 946e83b0..caaaff9d 100644 --- a/frontend/src/pages/Chat/components/InputField.tsx +++ b/frontend/src/pages/Chat/components/InputField.tsx @@ -1,16 +1,16 @@ import { useCallback, useEffect } from "react"; import { HumanMessage } from "@langchain/core/messages"; -import { type TChatMessage } from "../../../hooks/useMessages"; +import { type ChatMessage } from "../../../hooks/useMessages"; +import type { Location } from "../../../types/models"; import { streamText } from "../utils/streamHelper"; import useHousingContext from "../../../hooks/useHousingContext"; import clsx from "clsx"; interface Props { - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; - setMessages: React.Dispatch>; + addMessage: ( + args: Location, + ) => Promise | undefined>; + setMessages: React.Dispatch>; isLoading: boolean; setIsLoading: React.Dispatch>; value: string; diff --git a/frontend/src/pages/Chat/components/MessageContent.tsx b/frontend/src/pages/Chat/components/MessageContent.tsx index c8fbb6a8..a75b9457 100644 --- a/frontend/src/pages/Chat/components/MessageContent.tsx +++ b/frontend/src/pages/Chat/components/MessageContent.tsx @@ -1,9 +1,9 @@ import SafeMarkdown from "../../../shared/components/SafeMarkdown"; -import type { TChatMessage } from "../../../hooks/useMessages"; -import type { TResponseChunk } from "../../../types/MessageTypes"; +import type { ChatMessage } from "../../../hooks/useMessages"; +import type { ResponseChunk } from "../../../types/models"; interface ChunkProps { - chunkObj: TResponseChunk; + chunkObj: ResponseChunk; } function RenderedChunk({ chunkObj }: ChunkProps) { @@ -31,7 +31,7 @@ function hasRenderableContent(text: string): boolean { .filter(Boolean) .some((chunk) => { try { - const parsed = JSON.parse(chunk) as TResponseChunk; + const parsed = JSON.parse(chunk) as ResponseChunk; return ( (parsed.type === "text" && (parsed.content?.length ?? 0) > 0) || (parsed.type === "reasoning" && (parsed.content?.length ?? 0) > 0) @@ -43,7 +43,7 @@ function hasRenderableContent(text: string): boolean { } interface Props { - message: TChatMessage; + message: ChatMessage; } /** @@ -81,11 +81,11 @@ export default function MessageContent({ message }: Props) { .filter((chunk) => chunk.length !== 0) .map((chunk, index) => { try { - const chunkObj = JSON.parse(chunk) as TResponseChunk; + const chunkObj = JSON.parse(chunk) as ResponseChunk; // type prefix avoids bare index, which React warns against return ( ); diff --git a/frontend/src/pages/Chat/components/MessageWindow.tsx b/frontend/src/pages/Chat/components/MessageWindow.tsx index 25bb3de9..bece0ad1 100644 --- a/frontend/src/pages/Chat/components/MessageWindow.tsx +++ b/frontend/src/pages/Chat/components/MessageWindow.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import type { TChatMessage } from "../../../hooks/useMessages"; +import type { ChatMessage } from "../../../hooks/useMessages"; +import type { Location } from "../../../types/models"; import InputField from "./InputField"; import MessageContent from "./MessageContent"; import ExportMessagesButton from "./ExportMessagesButton"; @@ -9,12 +10,11 @@ import { useLocation } from "react-router-dom"; import clsx from "clsx"; interface Props { - messages: TChatMessage[]; - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; - setMessages: React.Dispatch>; + messages: ChatMessage[]; + addMessage: ( + args: Location, + ) => Promise | undefined>; + setMessages: React.Dispatch>; isOngoing: boolean; } diff --git a/frontend/src/pages/Chat/utils/exportHelper.ts b/frontend/src/pages/Chat/utils/exportHelper.ts index 4d98b73e..719282e2 100644 --- a/frontend/src/pages/Chat/utils/exportHelper.ts +++ b/frontend/src/pages/Chat/utils/exportHelper.ts @@ -1,7 +1,7 @@ import { deserializeAiMessage, - type TChatMessage, - type TUiMessage, + type ChatMessage, + type UiMessage, } from "../../../hooks/useMessages"; import sanitizeText from "../../../shared/utils/sanitizeText"; @@ -9,13 +9,13 @@ import sanitizeText from "../../../shared/utils/sanitizeText"; * Opens a printable window with the conversation history. * Sanitizes message content before rendering to prevent XSS. */ -export default function exportMessages(messages: TChatMessage[]) { +export default function exportMessages(messages: ChatMessage[]) { if (messages.length < 2) return; const newDocument = window.open("", "", "height=800,width=600"); const messageChain = messages .filter( - (msg): msg is Exclude => msg.type !== "ui", + (msg): msg is Exclude => msg.type !== "ui", ) .map( (msg) => diff --git a/frontend/src/pages/Chat/utils/feedbackHelper.ts b/frontend/src/pages/Chat/utils/feedbackHelper.ts index ccf6abbf..b86329a1 100644 --- a/frontend/src/pages/Chat/utils/feedbackHelper.ts +++ b/frontend/src/pages/Chat/utils/feedbackHelper.ts @@ -1,7 +1,7 @@ import { deserializeAiMessage, - type TChatMessage, - type TUiMessage, + type ChatMessage, + type UiMessage, } from "../../../hooks/useMessages"; import sanitizeText from "../../../shared/utils/sanitizeText"; @@ -30,7 +30,7 @@ function redactText(message: string, wordsToRedact: string) { * Builds an HTML transcript, applies word redaction, and sends via FormData. */ export default async function sendFeedback( - messages: TChatMessage[], + messages: ChatMessage[], userFeedback: string, emailsToCC: string, wordsToRedact: string, @@ -39,7 +39,7 @@ export default async function sendFeedback( const messageChain = messages .filter( - (msg): msg is Exclude => msg.type !== "ui", + (msg): msg is Exclude => msg.type !== "ui", ) .map( (msg) => diff --git a/frontend/src/pages/Chat/utils/formHelper.ts b/frontend/src/pages/Chat/utils/formHelper.ts index f026d403..f6b89ea9 100644 --- a/frontend/src/pages/Chat/utils/formHelper.ts +++ b/frontend/src/pages/Chat/utils/formHelper.ts @@ -1,6 +1,11 @@ -import { ILocation } from "../../../contexts/HousingContext"; +import { formatLocation } from "../../../shared/utils/formatLocation"; +import type { Location } from "../../../types/models"; +import type { + HousingType, + TenantTopic, +} from "../../../shared/constants/constants"; -interface IChatFormReturnType { +interface ChatFormReturnType { userMessage: string; } @@ -14,18 +19,15 @@ interface IChatFormReturnType { * @returns Object containing the formatted user message */ function buildChatUserMessage( - loc: ILocation, - housingType: string | null, - tenantTopic: string | null, + loc: Location, + housingType: HousingType | null, + tenantTopic: TenantTopic | null, issueDescription: string, -): IChatFormReturnType { - const locationString = - loc.city && loc.state - ? `${loc.city}, ${loc.state}` - : loc.city || loc.state || ""; +): ChatFormReturnType { + const locationString = formatLocation(loc.city, loc.state); const promptParts = [ - `I'm in ${locationString ? `${locationString}` : ""}.`, + ...(locationString ? [`I'm in ${locationString}.`] : []), `I live in ${housingType}.`, `My issue is on ${tenantTopic}: ${issueDescription === "" ? "Non-specific." : issueDescription}`, ]; diff --git a/frontend/src/pages/Chat/utils/streamHelper.ts b/frontend/src/pages/Chat/utils/streamHelper.ts index 275e0523..2f4c9a1f 100644 --- a/frontend/src/pages/Chat/utils/streamHelper.ts +++ b/frontend/src/pages/Chat/utils/streamHelper.ts @@ -1,17 +1,16 @@ import { AIMessage } from "@langchain/core/messages"; -import { ILocation } from "../../../contexts/HousingContext"; -import { type TChatMessage, type TUiMessage } from "../../../hooks/useMessages"; +import type { Location } from "../../../types/models"; +import { type ChatMessage, type UiMessage } from "../../../hooks/useMessages"; /** * Options for streaming AI responses into the chat message list. */ -export interface IStreamTextOptions { - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; - setMessages: React.Dispatch>; - housingLocation: ILocation; +export interface StreamTextOptions { + addMessage: ( + args: Location, + ) => Promise | undefined>; + setMessages: React.Dispatch>; + housingLocation: Location; setIsLoading?: React.Dispatch>; } @@ -27,7 +26,7 @@ async function streamText({ setMessages, housingLocation, setIsLoading, -}: IStreamTextOptions): Promise { +}: StreamTextOptions): Promise { const botMessageId = (Date.now() + 1).toString(); setIsLoading?.(true); @@ -39,13 +38,10 @@ async function streamText({ ]); try { - const reader = await addMessage({ - city: housingLocation?.city, - state: housingLocation?.state || "", - }); + const reader = await addMessage(housingLocation); if (!reader) { console.error("Stream reader is unavailable"); - const nullReaderError: TUiMessage = { + const nullReaderError: UiMessage = { type: "ui", text: "Sorry, I encountered an error. Please try again.", id: botMessageId, @@ -91,7 +87,7 @@ async function streamText({ } } catch (error) { console.error("Error:", error); - const errorMessage: TUiMessage = { + const errorMessage: UiMessage = { type: "ui", text: "Sorry, I encountered an error. Please try again.", id: botMessageId, diff --git a/frontend/src/pages/Letter/utils/letterHelper.ts b/frontend/src/pages/Letter/utils/letterHelper.ts index cb33fc0c..b35ac6ea 100644 --- a/frontend/src/pages/Letter/utils/letterHelper.ts +++ b/frontend/src/pages/Letter/utils/letterHelper.ts @@ -2,8 +2,9 @@ import { CITY_SELECT_OPTIONS, type CitySelectOptionType, } from "../../../shared/constants/constants"; +import { formatLocation } from "../../../shared/utils/formatLocation"; -interface IBuildLetterReturnType { +interface BuildLetterReturnType { userMessage: string; selectedLocation: CitySelectOptionType; } @@ -11,13 +12,14 @@ interface IBuildLetterReturnType { function buildLetterUserMessage( org: string | undefined, loc: string | undefined, -): IBuildLetterReturnType | null { - const selectedLocation = CITY_SELECT_OPTIONS[loc || "oregon"]; +): BuildLetterReturnType | null { + const key = (loc ?? "oregon") as keyof typeof CITY_SELECT_OPTIONS; + const selectedLocation = CITY_SELECT_OPTIONS[key]; if (selectedLocation === undefined) return null; - const locationString = - selectedLocation.city && selectedLocation.state - ? `${selectedLocation.city}, ${selectedLocation.state}` - : selectedLocation.city || selectedLocation.state?.toUpperCase() || ""; + const locationString = formatLocation( + selectedLocation.city, + selectedLocation.state, + ); const CHARACTER_LIMIT = 100; // Limit character count to prevent token overflow const sanitizedOrg = org diff --git a/frontend/src/shared/constants/constants.ts b/frontend/src/shared/constants/constants.ts index d6a4f95c..803b3302 100644 --- a/frontend/src/shared/constants/constants.ts +++ b/frontend/src/shared/constants/constants.ts @@ -1,25 +1,30 @@ +import type { OregonCity, UsaState } from "../../types/models"; + const CONTACT_EMAIL = "michael@qiu-qiulaw.com"; interface CitySelectOptionType { - city: string | null; - state: string | null; + city: OregonCity | null; + state: UsaState | null; label: string; } -const CITY_SELECT_OPTIONS: Record = { +const CITY_SELECT_OPTIONS: Record< + OregonCity | "oregon" | "other", + CitySelectOptionType +> = { portland: { - city: "Portland", - state: "OR", + city: "portland", + state: "or", label: "Portland", }, eugene: { - city: "Eugene", - state: "OR", + city: "eugene", + state: "or", label: "Eugene", }, oregon: { city: null, - state: "OR", + state: "or", label: "Other city in Oregon", }, other: { @@ -159,3 +164,7 @@ export { }; export type { CitySelectOptionType }; + +export type CitySelectKey = keyof typeof CITY_SELECT_OPTIONS; +export type HousingType = (typeof HOUSING_OPTIONS)[number]; +export type TenantTopic = keyof typeof ALL_TOPIC_OPTIONS; diff --git a/frontend/src/shared/utils/formatLocation.ts b/frontend/src/shared/utils/formatLocation.ts new file mode 100644 index 00000000..6c39f874 --- /dev/null +++ b/frontend/src/shared/utils/formatLocation.ts @@ -0,0 +1,20 @@ +import type { OregonCity, UsaState } from "../../types/models"; + +/** + * Formats a city and state into a human-readable location string. + * + * @returns A display string like "Portland, OR", "OR", or "" if both are null. + */ +function formatLocation( + city: OregonCity | null | undefined, + state: UsaState | null | undefined, +): string { + const cityDisplay = city + ? city.charAt(0).toUpperCase() + city.slice(1) + : null; + const stateDisplay = state && state !== "other" ? state.toUpperCase() : null; + + return [cityDisplay, stateDisplay].filter(Boolean).join(", "); +} + +export { formatLocation }; diff --git a/frontend/src/tests/components/HousingContext.test.tsx b/frontend/src/tests/components/HousingContext.test.tsx index 1e0c43fe..86249a89 100644 --- a/frontend/src/tests/components/HousingContext.test.tsx +++ b/frontend/src/tests/components/HousingContext.test.tsx @@ -13,7 +13,7 @@ function UpdateLocationButton() { return ( @@ -43,12 +43,12 @@ describe("HousingContext", () => { , ); - expect(screen.getByTestId("ctx").textContent).not.toContain("Test"); + expect(screen.getByTestId("ctx").textContent).not.toContain("portland"); fireEvent.click(screen.getByTestId("update-loc")); - expect(screen.getByTestId("ctx").textContent).toContain("Test"); - expect(screen.getByTestId("ctx").textContent).toContain("TS"); + expect(screen.getByTestId("ctx").textContent).toContain("portland"); + expect(screen.getByTestId("ctx").textContent).toContain('"or"'); }); it("throws error when used outside provider", () => { diff --git a/frontend/src/tests/components/Letter.test.tsx b/frontend/src/tests/components/Letter.test.tsx index da32dc6d..a6914830 100644 --- a/frontend/src/tests/components/Letter.test.tsx +++ b/frontend/src/tests/components/Letter.test.tsx @@ -17,7 +17,7 @@ import { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { HumanMessage } from "@langchain/core/messages"; -import { TChatMessage } from "../../hooks/useMessages"; +import { ChatMessage } from "../../hooks/useMessages"; beforeAll(() => { if (!("scrollTo" in HTMLElement.prototype)) { @@ -206,7 +206,7 @@ describe("Letter component - effect orchestration", () => { const setMessagesCall = mockSetMessages.mock.calls.find((call) => { const result = call[0]([]); - return result.some((msg: TChatMessage) => + return result.some((msg: ChatMessage) => msg.text.includes("Unable to generate letter"), ); }); diff --git a/frontend/src/tests/components/MessageContent.test.tsx b/frontend/src/tests/components/MessageContent.test.tsx index 7f30b856..29cf6d47 100644 --- a/frontend/src/tests/components/MessageContent.test.tsx +++ b/frontend/src/tests/components/MessageContent.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import { describe, it, expect } from "vitest"; import MessageContent from "../../pages/Chat/components/MessageContent"; -import type { TUiMessage } from "../../hooks/useMessages"; +import type { UiMessage } from "../../hooks/useMessages"; describe("MessageContent", () => { it("renders text chunk for AI message", () => { @@ -61,7 +61,7 @@ describe("MessageContent", () => { }); it("renders ui message using Info: label", () => { - const message: TUiMessage = { + const message: UiMessage = { type: "ui", text: "What was generated is just an initial template.", id: "6", diff --git a/frontend/src/tests/components/MessageWindow.test.tsx b/frontend/src/tests/components/MessageWindow.test.tsx index 96646405..d5493b48 100644 --- a/frontend/src/tests/components/MessageWindow.test.tsx +++ b/frontend/src/tests/components/MessageWindow.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import MessageWindow from "../../pages/Chat/components/MessageWindow"; -import { TChatMessage } from "../../hooks/useMessages"; +import { ChatMessage } from "../../hooks/useMessages"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import HousingContextProvider from "../../contexts/HousingContext"; @@ -15,7 +15,7 @@ beforeAll(() => { }); describe("MessageWindow component", () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "first message", id: "1" }), new AIMessage({ content: '{"type":"text","content":"second message"}\n', diff --git a/frontend/src/tests/hooks/useLetterContent.test.tsx b/frontend/src/tests/hooks/useLetterContent.test.tsx index ae020b47..1ec7ebde 100644 --- a/frontend/src/tests/hooks/useLetterContent.test.tsx +++ b/frontend/src/tests/hooks/useLetterContent.test.tsx @@ -2,7 +2,7 @@ import { renderHook } from "@testing-library/react"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import { describe, it, expect } from "vitest"; import { useLetterContent } from "../../hooks/useLetterContent"; -import type { TChatMessage } from "../../hooks/useMessages"; +import type { ChatMessage } from "../../hooks/useMessages"; const letterChunk = '{"type":"letter","content":"Dear Landlord, please fix the heat."}\n'; @@ -10,7 +10,7 @@ const textChunk = '{"type":"text","content":"Here is your letter."}\n'; describe("useLetterContent", () => { it("returns letter content from a letter chunk in an AI message", () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new AIMessage({ content: textChunk + letterChunk, id: "1" }), ]; const { result } = renderHook(() => useLetterContent(messages)); @@ -20,7 +20,7 @@ describe("useLetterContent", () => { }); it("returns the last letter chunk when multiple messages contain one", () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new AIMessage({ content: '{"type":"letter","content":"Old letter."}\n', id: "1", @@ -35,7 +35,7 @@ describe("useLetterContent", () => { }); it("returns empty string when no message contains a letter chunk", () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Write me a letter.", id: "1" }), new AIMessage({ content: textChunk, id: "2" }), ]; diff --git a/frontend/src/tests/utils/exportHelper.test.ts b/frontend/src/tests/utils/exportHelper.test.ts index f4464400..6b2ab8b1 100644 --- a/frontend/src/tests/utils/exportHelper.test.ts +++ b/frontend/src/tests/utils/exportHelper.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import exportMessages from "../../pages/Chat/utils/exportHelper"; -import { TChatMessage, TUiMessage } from "../../hooks/useMessages"; +import { ChatMessage, UiMessage } from "../../hooks/useMessages"; function createMockDocument() { const writelnCalls: string[] = []; @@ -46,7 +46,7 @@ describe("exportMessages", () => { }); it("should open window, generate HTML with sanitized content, and trigger print", () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: '', id: "1", @@ -89,7 +89,7 @@ describe("exportMessages", () => { it("should deserialize JSONL AI message content to plain text", () => { const jsonlContent = '{"type":"text","content":"Here is your answer."}\n{"type":"letter","content":"Dear Landlord,\\n\\nPlease fix the heater."}'; - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Write a letter", id: "1" }), new AIMessage({ content: jsonlContent, id: "2" }), ]; @@ -104,12 +104,12 @@ describe("exportMessages", () => { }); it("should exclude ui messages from export", () => { - const uiMessage: TUiMessage = { + const uiMessage: UiMessage = { type: "ui", text: "Sorry, I encountered an error.", id: "3", }; - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Hello", id: "1" }), new AIMessage({ content: "Hi there", id: "2" }), uiMessage, diff --git a/frontend/src/tests/utils/feedbackHelper.test.ts b/frontend/src/tests/utils/feedbackHelper.test.ts index 385d3687..2416bbb2 100644 --- a/frontend/src/tests/utils/feedbackHelper.test.ts +++ b/frontend/src/tests/utils/feedbackHelper.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import sendFeedback from "../../pages/Chat/utils/feedbackHelper"; -import { TChatMessage, TUiMessage } from "../../hooks/useMessages"; +import { ChatMessage, UiMessage } from "../../hooks/useMessages"; describe("sendFeedback", () => { let fetchSpy: ReturnType; @@ -41,7 +41,7 @@ describe("sendFeedback", () => { it("should deserialize JSONL AI message content to plain text", async () => { const jsonlContent = '{"type":"text","content":"Here is your answer."}\n{"type":"letter","content":"Dear Landlord,\\n\\nPlease fix the heater."}'; - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Write a letter", id: "1" }), new AIMessage({ content: jsonlContent, id: "2" }), ]; @@ -56,12 +56,12 @@ describe("sendFeedback", () => { }); it("should exclude ui messages from the transcript", async () => { - const uiMessage: TUiMessage = { + const uiMessage: UiMessage = { type: "ui", text: "Sorry, I encountered an error.", id: "3", }; - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Hello", id: "1" }), new AIMessage({ content: "Hi there", id: "2" }), uiMessage, @@ -76,7 +76,7 @@ describe("sendFeedback", () => { }); it("should redact specified words from the transcript", async () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "My name is John Smith", id: "1" }), new AIMessage({ content: "Hello John Smith", id: "2" }), ]; @@ -89,7 +89,7 @@ describe("sendFeedback", () => { }); it("should post to /api/feedback with correct form fields", async () => { - const messages: TChatMessage[] = [ + const messages: ChatMessage[] = [ new HumanMessage({ content: "Hello", id: "1" }), new AIMessage({ content: "Hi", id: "2" }), ]; diff --git a/frontend/src/tests/utils/formHelper.test.ts b/frontend/src/tests/utils/formHelper.test.ts index 5f1877ab..033d2803 100644 --- a/frontend/src/tests/utils/formHelper.test.ts +++ b/frontend/src/tests/utils/formHelper.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "vitest"; import { buildChatUserMessage } from "../../pages/Chat/utils/formHelper"; -import { ILocation } from "../../contexts/HousingContext"; +import type { Location } from "../../types/models"; describe("buildChatUserMessage", () => { it("builds message with all fields populated", () => { - const location: ILocation = { - city: "Portland", - state: "OR", + const location: Location = { + city: "portland", + state: "or", }; const housingType = "Apartment/House Rental"; const tenantTopic = "Eviction and Notices"; @@ -28,9 +28,9 @@ describe("buildChatUserMessage", () => { }); it("handles null city gracefully", () => { - const location: ILocation = { + const location: Location = { city: null, - state: "OR", + state: "or", }; const housingType = "Apartment/House Rental"; const tenantTopic = "Rent Issues"; @@ -47,10 +47,20 @@ describe("buildChatUserMessage", () => { expect(result.userMessage).not.toContain("null"); }); + it("omits location sentence when city and state are both null", () => { + const result = buildChatUserMessage( + { city: null, state: null }, + "Apartment/House Rental", + "Eviction and Notices", + "Some issue", + ); + expect(result.userMessage).not.toContain("I'm in"); + }); + it("includes all prompt parts", () => { - const location: ILocation = { - city: "Portland", - state: "OR", + const location: Location = { + city: "portland", + state: "or", }; const housingType = "Apartment/House Rental"; const tenantTopic = "Eviction and Notices"; diff --git a/frontend/src/tests/utils/formatLocation.test.ts b/frontend/src/tests/utils/formatLocation.test.ts new file mode 100644 index 00000000..0d488086 --- /dev/null +++ b/frontend/src/tests/utils/formatLocation.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { formatLocation } from "../../shared/utils/formatLocation"; + +describe("formatLocation", () => { + it("formats city and state together", () => { + expect(formatLocation("portland", "or")).toBe("Portland, OR"); + }); + + it("uppercases state with null city", () => { + expect(formatLocation(null, "or")).toBe("OR"); + }); + + it("omits state when state is 'other'", () => { + expect(formatLocation(null, "other")).toBe(""); + }); + + it("returns only city when state is 'other'", () => { + expect(formatLocation("portland", "other")).toBe("Portland"); + }); +}); diff --git a/frontend/src/tests/utils/streamHelper.test.ts b/frontend/src/tests/utils/streamHelper.test.ts index d9bf974c..87a33a74 100644 --- a/frontend/src/tests/utils/streamHelper.test.ts +++ b/frontend/src/tests/utils/streamHelper.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import { streamText, - type IStreamTextOptions, + type StreamTextOptions, } from "../../pages/Chat/utils/streamHelper"; function createMockReader( @@ -52,14 +52,14 @@ describe("streamText", () => { const result = await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(result).toBe(true); expect(mockAddMessage).toHaveBeenCalledWith({ - city: "Portland", - state: "OR", + city: "portland", + state: "or", }); expect(mockSetMessages).toHaveBeenCalledTimes(3); // 1 initial + 2 chunk updates @@ -79,9 +79,9 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); const calls = mockSetMessages.mock.calls; const updateCall = calls[calls.length - 1][0]; @@ -104,9 +104,9 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(mockSetIsLoading).toHaveBeenCalledWith(false); expect(console.error).toHaveBeenCalledWith("Error:", expect.any(Error)); @@ -132,9 +132,9 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); // 1 initial + 2 chunk updates expect(mockSetMessages).toHaveBeenCalledTimes(3); @@ -161,9 +161,9 @@ describe("streamText", () => { const result = await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(result).toBe(true); expect(mockSetMessages).toHaveBeenCalledTimes(3); // 1 initial + 2 chunk updates @@ -184,15 +184,15 @@ describe("streamText", () => { const result = await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(result).toBeUndefined(); expect(console.error).toHaveBeenCalledWith("Stream reader is unavailable"); expect(mockSetIsLoading).toHaveBeenCalledWith(false); // setMessages is called twice: once to add the empty placeholder, once to replace - // it with a TUiMessage error so the letter page slice(2) can show the error. + // it with a UiMessage error so the letter page slice(2) can show the error. expect(mockSetMessages).toHaveBeenCalledTimes(2); const replaceCall = mockSetMessages.mock.calls[1][0]; const result2 = replaceCall([ diff --git a/frontend/src/types/MessageTypes.ts b/frontend/src/types/MessageTypes.ts deleted file mode 100644 index 54a66717..00000000 --- a/frontend/src/types/MessageTypes.ts +++ /dev/null @@ -1,18 +0,0 @@ -interface ITextChunk { - type: "text"; - content: string; -} - -interface IReasoningChunk { - type: "reasoning"; - content: string; -} - -interface ILetterChunk { - type: "letter"; - content: string; -} - -type TResponseChunk = ITextChunk | IReasoningChunk | ILetterChunk; - -export type { TResponseChunk, IReasoningChunk, ITextChunk, ILetterChunk };