From 3c31edfa09e0fd5092de03cab5cf2c87b1decc0f Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 01:20:17 -0800 Subject: [PATCH 01/18] Updated Architecture.md; Created python script to auto-generate types based on schema.py and location.py; Tightened frontend types; Updated unit tests and files to import new types; Included helper function to help format city/state --- Architecture.md | 18 ++-- backend/Makefile | 6 +- backend/scripts/generate_types.py | 101 ++++++++++++++++++ backend/tenantfirstaid/location.py | 8 ++ frontend/src/Letter.tsx | 2 +- frontend/src/contexts/HousingContext.tsx | 35 +++--- frontend/src/hooks/useMessages.tsx | 14 +-- .../Chat/components/InitializationForm.tsx | 28 +++-- .../src/pages/Chat/components/InputField.tsx | 6 +- .../pages/Chat/components/MessageWindow.tsx | 6 +- frontend/src/pages/Chat/utils/formHelper.ts | 13 ++- frontend/src/pages/Chat/utils/streamHelper.ts | 12 +-- .../src/pages/Letter/utils/letterHelper.ts | 9 +- frontend/src/shared/constants/constants.ts | 22 ++-- frontend/src/shared/utils/formatLocation.ts | 18 ++++ .../tests/components/HousingContext.test.tsx | 8 +- frontend/src/tests/utils/formHelper.test.ts | 12 +-- .../src/tests/utils/formatLocation.test.ts | 20 ++++ frontend/src/tests/utils/streamHelper.test.ts | 16 +-- frontend/src/types/HousingTypes.ts | 11 ++ frontend/src/types/LocationTypes.ts | 8 ++ frontend/src/types/MessageTypes.ts | 5 +- 22 files changed, 270 insertions(+), 108 deletions(-) create mode 100644 backend/scripts/generate_types.py create mode 100644 frontend/src/shared/utils/formatLocation.ts create mode 100644 frontend/src/tests/utils/formatLocation.test.ts create mode 100644 frontend/src/types/HousingTypes.ts create mode 100644 frontend/src/types/LocationTypes.ts diff --git a/Architecture.md b/Architecture.md index ec20a7ce..7a0ca772 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 # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes,HousingTypes}.ts from Pydantic/StrEnum models │ └── 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,10 @@ 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 — do not edit manually, re-run `make generate-types` +│ │ ├── MessageTypes.ts # TResponseChunk union and chunk interfaces (from schema.py) +│ │ ├── LocationTypes.ts # TOregonCity and TUsaState string literal types (from location.py) +│ │ └── HousingTypes.ts # ILocation interface (from location.py) │ ├── layouts/ # Layouts │ │ └── PageLayout.tsx # Layout for pages │ ├── pages/ @@ -511,7 +511,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 TOregonCity/TUsaState into a display string (e.g. "Portland, OR") │ └── tests/ # Testing suite │ │ ├── components/ # Component testing │ │ │ ├── About.test.tsx # About component testing @@ -536,7 +537,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/backend/Makefile b/backend/Makefile index 0404bdf8..f8d117f1 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,9 @@ typecheck-pyrefly: uv.lock test: uv.lock uv run pytest -v -s $(TEST_OPTIONS) +generate-types: uv.lock + $(PYTHON) run python scripts/generate_types.py $(FRONTEND_DIR)/src/types + clean: find . -type d -name '__pycache__' -exec rm -r {} + rm -rf dist build *.egg-info diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py new file mode 100644 index 00000000..e6815543 --- /dev/null +++ b/backend/scripts/generate_types.py @@ -0,0 +1,101 @@ +"""Generate frontend TypeScript types from backend Pydantic models and enums. + +Run with: uv run python scripts/generate_types.py ../frontend/src/types/ +Output: /MessageTypes.ts, /LocationTypes.ts, /HousingTypes.ts +""" + +import sys +from enum import StrEnum +from pathlib import Path +from types import UnionType +from typing import Literal, Union, get_args, get_origin + +from pydantic import BaseModel +from tenantfirstaid.location import Location, OregonCity, UsaState +from tenantfirstaid.schema import ResponseChunk + +CHUNK_MODELS = list(get_args(ResponseChunk)) +LOCATION_ENUMS: list[type[StrEnum]] = [OregonCity, UsaState] + +HEADER = ( + "// This file is auto-generated by backend/scripts/generate_types.py.\n" + "// Do not edit manually — re-run `make generate-types`." +) + + +def py_annotation_to_ts(annotation: type) -> str: + """Convert a Python type annotation to a TypeScript type string.""" + origin = get_origin(annotation) + args = get_args(annotation) + + if origin is Literal: + return " | ".join(f'"{a}"' for a in args) + if annotation is str: + return "string" + if isinstance(annotation, type) and issubclass(annotation, StrEnum): + return f"T{annotation.__name__}" + if origin in (UnionType, Union): + ts_parts = [py_annotation_to_ts(a) for a in args if a is not type(None)] + if type(None) in args: + ts_parts.append("null") + return " | ".join(ts_parts) + + raise TypeError(f"Unsupported annotation for TypeScript codegen: {annotation!r}") + + +def model_to_interface(model: type[BaseModel]) -> str: + """Render a Pydantic BaseModel as a TypeScript interface.""" + lines = [f"interface I{model.__name__} {{"] + for field_name, field_info in model.model_fields.items(): + lines.append(f" {field_name}: {py_annotation_to_ts(field_info.annotation)};") + lines.append("}") + return "\n".join(lines) + + +def enum_to_ts_type(enum: type[StrEnum]) -> str: + """Render a StrEnum as a TypeScript type alias.""" + members = " | ".join(f'"{e.value}"' for e in enum) + return f"type T{enum.__name__} = {members};" + + +def make_file(blocks: list[str], exports: list[str], imports: str = "") -> str: + """Assemble a TypeScript file from blocks, with optional imports.""" + parts = [HEADER] + if imports: + parts.append(imports) + parts.extend(blocks) + parts.append(f"export type {{ {', '.join(exports)} }};") + return "\n\n".join(parts) + "\n" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + types_dir = Path(sys.argv[1]) + + ts_names = [f"I{m.__name__}" for m in CHUNK_MODELS] + interfaces = [model_to_interface(m) for m in CHUNK_MODELS] + union_alias = f"type TResponseChunk = {' | '.join(ts_names)};" + + files = { + "MessageTypes.ts": make_file( + interfaces + [union_alias], + ["TResponseChunk", *ts_names], + ), + "LocationTypes.ts": make_file( + [enum_to_ts_type(e) for e in LOCATION_ENUMS], + [f"T{e.__name__}" for e in LOCATION_ENUMS], + ), + "HousingTypes.ts": make_file( + [model_to_interface(Location)], + [f"I{Location.__name__}"], + imports='import type { TOregonCity, TUsaState } from "./LocationTypes";', + ), + } + + for filename, content in files.items(): + path = types_dir / filename + path.write_text(content) + print(f"Written to {path}") diff --git a/backend/tenantfirstaid/location.py b/backend/tenantfirstaid/location.py index adbf21a9..f1c84b9d 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,13 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState": return state +class Location(BaseModel): + """Location data as received from the frontend request.""" + + city: OregonCity | None = None + state: UsaState | None = None + + class TFAAgentStateSchema(AgentState): state: UsaState city: Optional[OregonCity] diff --git a/frontend/src/Letter.tsx b/frontend/src/Letter.tsx index 0b8fe8bf..3e739179 100644 --- a/frontend/src/Letter.tsx +++ b/frontend/src/Letter.tsx @@ -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 { ILocation } from "./types/HousingTypes"; import FeatureSnippet from "./shared/components/FeatureSnippet"; import clsx from "clsx"; diff --git a/frontend/src/contexts/HousingContext.tsx b/frontend/src/contexts/HousingContext.tsx index 1a774ecb..87eb6738 100644 --- a/frontend/src/contexts/HousingContext.tsx +++ b/frontend/src/contexts/HousingContext.tsx @@ -1,21 +1,22 @@ import { createContext, useCallback, useMemo, useState } from "react"; import DOMPurify, { SANITIZE_USER_SETTINGS } from "../shared/utils/dompurify"; - -export interface ILocation { - city: string | null; - state: string | null; -} +import type { ILocation } from "../types/HousingTypes"; +import type { + TCitySelectKey, + THousingType, + TTenantTopic, +} from "../shared/constants/constants"; export interface IHousingContextType { housingLocation: ILocation; - city: string | null; - housingType: string | null; - tenantTopic: string | null; + city: TCitySelectKey | null; + housingType: THousingType | null; + tenantTopic: TTenantTopic | null; issueDescription: string; handleHousingLocation: ({ city, state }: ILocation) => void; - handleCityChange: (option: string | null) => void; - handleHousingChange: (option: string | null) => void; - handleTenantTopic: (option: string | null) => void; + handleCityChange: (option: TCitySelectKey | null) => void; + handleHousingChange: (option: THousingType | null) => void; + handleTenantTopic: (option: TTenantTopic | null) => void; handleIssueDescription: ( event: React.ChangeEvent, ) => void; @@ -29,28 +30,28 @@ interface Props { } export default function HousingContextProvider({ children }: Props) { - const [city, setCity] = useState(null); + 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) => { setHousingLocation({ city, state }); }, []); - const handleCityChange = useCallback((option: string | null) => { + const handleCityChange = useCallback((option: TCitySelectKey | null) => { setCity(option); }, []); - const handleHousingChange = useCallback((option: string | null) => { + const handleHousingChange = useCallback((option: THousingType | null) => { setHousingType(option); }, []); - const handleTenantTopic = useCallback((option: string | null) => { + const handleTenantTopic = useCallback((option: TTenantTopic | null) => { setTenantTopic(option); }, []); diff --git a/frontend/src/hooks/useMessages.tsx b/frontend/src/hooks/useMessages.tsx index dcef28e8..0b7df216 100644 --- a/frontend/src/hooks/useMessages.tsx +++ b/frontend/src/hooks/useMessages.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import type { AIMessage, HumanMessage } from "@langchain/core/messages"; +import type { ILocation } from "../types/HousingTypes"; /** * Chat message Type aligned with LangChain's message types @@ -37,8 +38,7 @@ export function deserializeAiMessage(text: string): string { async function addNewMessage( messages: TChatMessage[], - city: string | null, - state: string, + { city, state }: ILocation, ) { const serializedMsg = messages.map((msg) => ({ role: msg.type, @@ -62,19 +62,13 @@ export default function useMessages() { const [messages, setMessages] = useState([]); const addMessage = useMutation({ - mutationFn: async ({ - city, - state, - }: { - city: string | null; - state: string; - }) => { + mutationFn: async ({ city, state }: ILocation) => { // Exclude UI-only messages and empty placeholders from backend history. const filteredMessages = messages.filter( (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/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index 44b75c8b..1e2b1c5f 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 BeaverIcon from "../../../shared/components/BeaverIcon"; +import type { ILocation } from "../../../types/HousingTypes"; +import { formatLocation } from "../../../shared/utils/formatLocation"; import { useEffect, useState } from "react"; import { buildChatUserMessage } from "../utils/formHelper"; import { streamText } from "../utils/streamHelper"; @@ -12,19 +14,19 @@ import { HOUSING_OPTIONS, LETTERABLE_TOPIC_OPTIONS, NONLETTERABLE_TOPIC_OPTIONS, + type TCitySelectKey, + type THousingType, + type TTenantTopic, } 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 TTenantTopic[]; interface Props { - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; + addMessage: (args: ILocation) => Promise | undefined>; setMessages: React.Dispatch>; } @@ -46,12 +48,10 @@ 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); + handleCityChange(key as TCitySelectKey | null); handleHousingLocation({ city: CITY_SELECT_OPTIONS[key as keyof typeof CITY_SELECT_OPTIONS]?.city || @@ -149,7 +149,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { name="housing type" value={housingType || ""} description="Select your housing type" - handleFunction={handleHousingChange} + handleFunction={(option) => handleHousingChange(option as THousingType | null)} > {HOUSING_OPTIONS.map((option) => ( {Object.entries(LETTERABLE_TOPIC_OPTIONS).map(([key, option]) => ( @@ -185,9 +185,7 @@ 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; @@ -239,7 +237,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { {housingLocation && housingType && tenantTopic && - !NONLETTERABLE_TOPICS.includes(tenantTopic) && + !(NONLETTERABLE_TOPICS.includes(tenantTopic)) && issueDescription && ( Promise | undefined>; + addMessage: (args: ILocation) => Promise | undefined>; setMessages: React.Dispatch>; isLoading: boolean; setIsLoading: React.Dispatch>; diff --git a/frontend/src/pages/Chat/components/MessageWindow.tsx b/frontend/src/pages/Chat/components/MessageWindow.tsx index 25bb3de9..9e9e3b2e 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 { ILocation } from "../../../types/HousingTypes"; import InputField from "./InputField"; import MessageContent from "./MessageContent"; import ExportMessagesButton from "./ExportMessagesButton"; @@ -10,10 +11,7 @@ import clsx from "clsx"; interface Props { messages: TChatMessage[]; - addMessage: (args: { - city: string | null; - state: string; - }) => Promise | undefined>; + addMessage: (args: ILocation) => Promise | undefined>; setMessages: React.Dispatch>; isOngoing: boolean; } diff --git a/frontend/src/pages/Chat/utils/formHelper.ts b/frontend/src/pages/Chat/utils/formHelper.ts index f026d403..a1ce4fe9 100644 --- a/frontend/src/pages/Chat/utils/formHelper.ts +++ b/frontend/src/pages/Chat/utils/formHelper.ts @@ -1,4 +1,6 @@ -import { ILocation } from "../../../contexts/HousingContext"; +import { formatLocation } from "../../../shared/utils/formatLocation"; +import type { ILocation } from "../../../types/HousingTypes"; +import type { THousingType, TTenantTopic } from "../../../shared/constants/constants"; interface IChatFormReturnType { userMessage: string; @@ -15,14 +17,11 @@ interface IChatFormReturnType { */ function buildChatUserMessage( loc: ILocation, - housingType: string | null, - tenantTopic: string | null, + housingType: THousingType | null, + tenantTopic: TTenantTopic | null, issueDescription: string, ): IChatFormReturnType { - const locationString = - loc.city && loc.state - ? `${loc.city}, ${loc.state}` - : loc.city || loc.state || ""; + const locationString = formatLocation(loc.city, loc.state); const promptParts = [ `I'm in ${locationString ? `${locationString}` : ""}.`, diff --git a/frontend/src/pages/Chat/utils/streamHelper.ts b/frontend/src/pages/Chat/utils/streamHelper.ts index 275e0523..1ef1da56 100644 --- a/frontend/src/pages/Chat/utils/streamHelper.ts +++ b/frontend/src/pages/Chat/utils/streamHelper.ts @@ -1,15 +1,12 @@ import { AIMessage } from "@langchain/core/messages"; -import { ILocation } from "../../../contexts/HousingContext"; +import type { ILocation } from "../../../types/HousingTypes"; import { type TChatMessage, type TUiMessage } 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>; + addMessage: (args: ILocation) => Promise | undefined>; setMessages: React.Dispatch>; housingLocation: ILocation; setIsLoading?: React.Dispatch>; @@ -39,10 +36,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 = { diff --git a/frontend/src/pages/Letter/utils/letterHelper.ts b/frontend/src/pages/Letter/utils/letterHelper.ts index cb33fc0c..47dec160 100644 --- a/frontend/src/pages/Letter/utils/letterHelper.ts +++ b/frontend/src/pages/Letter/utils/letterHelper.ts @@ -2,6 +2,7 @@ import { CITY_SELECT_OPTIONS, type CitySelectOptionType, } from "../../../shared/constants/constants"; +import { formatLocation } from "../../../shared/utils/formatLocation"; interface IBuildLetterReturnType { userMessage: string; @@ -12,12 +13,10 @@ function buildLetterUserMessage( org: string | undefined, loc: string | undefined, ): IBuildLetterReturnType | null { - const selectedLocation = CITY_SELECT_OPTIONS[loc || "oregon"]; + 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..e2a88d24 100644 --- a/frontend/src/shared/constants/constants.ts +++ b/frontend/src/shared/constants/constants.ts @@ -1,25 +1,27 @@ +import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; + const CONTACT_EMAIL = "michael@qiu-qiulaw.com"; interface CitySelectOptionType { - city: string | null; - state: string | null; + city: TOregonCity | null; + state: TUsaState | null; label: string; } -const CITY_SELECT_OPTIONS: Record = { +const CITY_SELECT_OPTIONS: Record = { 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 +161,7 @@ export { }; export type { CitySelectOptionType }; + +export type TCitySelectKey = keyof typeof CITY_SELECT_OPTIONS; +export type THousingType = (typeof HOUSING_OPTIONS)[number]; +export type TTenantTopic = 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..7d6d0d19 --- /dev/null +++ b/frontend/src/shared/utils/formatLocation.ts @@ -0,0 +1,18 @@ +import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; + +/** + * 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: TOregonCity | null, state: TUsaState | null): string { + const cityDisplay = city + ? city.charAt(0).toUpperCase() + city.slice(1) + : null; + const stateDisplay = state?.toUpperCase() ?? null; + + if (cityDisplay && stateDisplay) return `${cityDisplay}, ${stateDisplay}`; + return cityDisplay || stateDisplay || ""; +} + +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/utils/formHelper.test.ts b/frontend/src/tests/utils/formHelper.test.ts index 5f1877ab..8199baec 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 { ILocation } from "../../types/HousingTypes"; describe("buildChatUserMessage", () => { it("builds message with all fields populated", () => { const location: ILocation = { - city: "Portland", - state: "OR", + city: "portland", + state: "or", }; const housingType = "Apartment/House Rental"; const tenantTopic = "Eviction and Notices"; @@ -30,7 +30,7 @@ describe("buildChatUserMessage", () => { it("handles null city gracefully", () => { const location: ILocation = { city: null, - state: "OR", + state: "or", }; const housingType = "Apartment/House Rental"; const tenantTopic = "Rent Issues"; @@ -49,8 +49,8 @@ describe("buildChatUserMessage", () => { it("includes all prompt parts", () => { const location: ILocation = { - city: "Portland", - state: "OR", + 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..7e0f5fd5 --- /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("capitalizes city with null state", () => { + expect(formatLocation("eugene", null)).toBe("Eugene"); + }); + + it("uppercases state with null city", () => { + expect(formatLocation(null, "or")).toBe("OR"); + }); + + it("returns empty string when both are null", () => { + expect(formatLocation(null, null)).toBe(""); + }); +}); diff --git a/frontend/src/tests/utils/streamHelper.test.ts b/frontend/src/tests/utils/streamHelper.test.ts index d9bf974c..05993a90 100644 --- a/frontend/src/tests/utils/streamHelper.test.ts +++ b/frontend/src/tests/utils/streamHelper.test.ts @@ -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); 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,7 +79,7 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, } as IStreamTextOptions); @@ -104,7 +104,7 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, } as IStreamTextOptions); @@ -132,7 +132,7 @@ describe("streamText", () => { await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, } as IStreamTextOptions); @@ -161,7 +161,7 @@ describe("streamText", () => { const result = await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, } as IStreamTextOptions); @@ -184,7 +184,7 @@ describe("streamText", () => { const result = await streamText({ addMessage: mockAddMessage, setMessages: mockSetMessages, - housingLocation: { city: "Portland", state: "OR" }, + housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, } as IStreamTextOptions); diff --git a/frontend/src/types/HousingTypes.ts b/frontend/src/types/HousingTypes.ts new file mode 100644 index 00000000..67e700ce --- /dev/null +++ b/frontend/src/types/HousingTypes.ts @@ -0,0 +1,11 @@ +// This file is auto-generated by backend/scripts/generate_types.py. +// Do not edit manually — re-run `make generate-types`. + +import type { TOregonCity, TUsaState } from "./LocationTypes"; + +interface ILocation { + city: TOregonCity | null; + state: TUsaState | null; +} + +export type { ILocation }; diff --git a/frontend/src/types/LocationTypes.ts b/frontend/src/types/LocationTypes.ts new file mode 100644 index 00000000..5593736f --- /dev/null +++ b/frontend/src/types/LocationTypes.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by backend/scripts/generate_types.py. +// Do not edit manually — re-run `make generate-types`. + +type TOregonCity = "portland" | "eugene"; + +type TUsaState = "or" | "other"; + +export type { TOregonCity, TUsaState }; diff --git a/frontend/src/types/MessageTypes.ts b/frontend/src/types/MessageTypes.ts index 54a66717..a563e6c1 100644 --- a/frontend/src/types/MessageTypes.ts +++ b/frontend/src/types/MessageTypes.ts @@ -1,3 +1,6 @@ +// This file is auto-generated by backend/scripts/generate_types.py. +// Do not edit manually — re-run `make generate-types`. + interface ITextChunk { type: "text"; content: string; @@ -15,4 +18,4 @@ interface ILetterChunk { type TResponseChunk = ITextChunk | IReasoningChunk | ILetterChunk; -export type { TResponseChunk, IReasoningChunk, ITextChunk, ILetterChunk }; +export type { TResponseChunk, ITextChunk, IReasoningChunk, ILetterChunk }; From 9581a7ceed337aad9b086c777dcacf0105122b70 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 01:32:56 -0800 Subject: [PATCH 02/18] Fixed formatting --- backend/scripts/generate_types.py | 7 ++++- .../Chat/components/InitializationForm.tsx | 28 ++++++++++++++----- .../src/pages/Chat/components/InputField.tsx | 4 ++- .../pages/Chat/components/MessageWindow.tsx | 4 ++- frontend/src/pages/Chat/utils/formHelper.ts | 5 +++- frontend/src/pages/Chat/utils/streamHelper.ts | 4 ++- .../src/pages/Letter/utils/letterHelper.ts | 5 +++- frontend/src/shared/constants/constants.ts | 5 +++- frontend/src/shared/utils/formatLocation.ts | 5 +++- 9 files changed, 52 insertions(+), 15 deletions(-) diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index e6815543..e8df0fc6 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -11,6 +11,7 @@ from typing import Literal, Union, get_args, get_origin from pydantic import BaseModel + from tenantfirstaid.location import Location, OregonCity, UsaState from tenantfirstaid.schema import ResponseChunk @@ -24,7 +25,11 @@ def py_annotation_to_ts(annotation: type) -> str: - """Convert a Python type annotation to a TypeScript type string.""" + """Convert a Python type annotation to a TypeScript type string. + + Supported annotations: str, Literal, StrEnum subclasses, X | None, Optional[X]. + Extend this function if a new backend type is needed (e.g. list, int, bool). + """ origin = get_origin(annotation) args = get_args(annotation) diff --git a/frontend/src/pages/Chat/components/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index 1e2b1c5f..2c746356 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -23,10 +23,14 @@ import AutoExpandText from "./AutoExpandText"; import clsx from "clsx"; import { HumanMessage } from "@langchain/core/messages"; -const NONLETTERABLE_TOPICS = Object.keys(NONLETTERABLE_TOPIC_OPTIONS) as TTenantTopic[]; +const NONLETTERABLE_TOPICS = Object.keys( + NONLETTERABLE_TOPIC_OPTIONS, +) as TTenantTopic[]; interface Props { - addMessage: (args: ILocation) => Promise | undefined>; + addMessage: ( + args: ILocation, + ) => Promise | undefined>; setMessages: React.Dispatch>; } @@ -48,7 +52,10 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { handleFormReset, } = useHousingContext(); const [initialUserMessage, setInitialUserMessage] = useState(""); - const locationString = formatLocation(housingLocation.city, housingLocation.state); + const locationString = formatLocation( + housingLocation.city, + housingLocation.state, + ); const handleLocationChange = (key: string | null) => { handleCityChange(key as TCitySelectKey | null); @@ -149,7 +156,9 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { name="housing type" value={housingType || ""} description="Select your housing type" - handleFunction={(option) => handleHousingChange(option as THousingType | null)} + handleFunction={(option) => + handleHousingChange(option as THousingType | null) + } > {HOUSING_OPTIONS.map((option) => ( {Object.entries(LETTERABLE_TOPIC_OPTIONS).map(([key, option]) => ( @@ -185,7 +196,10 @@ export default function InitializationForm({ addMessage, setMessages }: Props) {
    Here are some examples of questions I can help with:
      - {(tenantTopic ? ALL_TOPIC_OPTIONS[tenantTopic] : null)?.example.map((question, index) => ( + {(tenantTopic + ? ALL_TOPIC_OPTIONS[tenantTopic] + : null + )?.example.map((question, index) => (
    • {question.split(/(_)/).map((part, i) => { if (!part.startsWith("_")) return part; @@ -237,7 +251,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { {housingLocation && housingType && tenantTopic && - !(NONLETTERABLE_TOPICS.includes(tenantTopic)) && + !NONLETTERABLE_TOPICS.includes(tenantTopic) && issueDescription && ( Promise | undefined>; + addMessage: ( + args: ILocation, + ) => Promise | undefined>; setMessages: React.Dispatch>; isLoading: boolean; setIsLoading: React.Dispatch>; diff --git a/frontend/src/pages/Chat/components/MessageWindow.tsx b/frontend/src/pages/Chat/components/MessageWindow.tsx index 9e9e3b2e..73d0909b 100644 --- a/frontend/src/pages/Chat/components/MessageWindow.tsx +++ b/frontend/src/pages/Chat/components/MessageWindow.tsx @@ -11,7 +11,9 @@ import clsx from "clsx"; interface Props { messages: TChatMessage[]; - addMessage: (args: ILocation) => Promise | undefined>; + addMessage: ( + args: ILocation, + ) => Promise | undefined>; setMessages: React.Dispatch>; isOngoing: boolean; } diff --git a/frontend/src/pages/Chat/utils/formHelper.ts b/frontend/src/pages/Chat/utils/formHelper.ts index a1ce4fe9..60d0cc5a 100644 --- a/frontend/src/pages/Chat/utils/formHelper.ts +++ b/frontend/src/pages/Chat/utils/formHelper.ts @@ -1,6 +1,9 @@ import { formatLocation } from "../../../shared/utils/formatLocation"; import type { ILocation } from "../../../types/HousingTypes"; -import type { THousingType, TTenantTopic } from "../../../shared/constants/constants"; +import type { + THousingType, + TTenantTopic, +} from "../../../shared/constants/constants"; interface IChatFormReturnType { userMessage: string; diff --git a/frontend/src/pages/Chat/utils/streamHelper.ts b/frontend/src/pages/Chat/utils/streamHelper.ts index 1ef1da56..b034bfb4 100644 --- a/frontend/src/pages/Chat/utils/streamHelper.ts +++ b/frontend/src/pages/Chat/utils/streamHelper.ts @@ -6,7 +6,9 @@ import { type TChatMessage, type TUiMessage } from "../../../hooks/useMessages"; * Options for streaming AI responses into the chat message list. */ export interface IStreamTextOptions { - addMessage: (args: ILocation) => Promise | undefined>; + addMessage: ( + args: ILocation, + ) => Promise | undefined>; setMessages: React.Dispatch>; housingLocation: ILocation; setIsLoading?: React.Dispatch>; diff --git a/frontend/src/pages/Letter/utils/letterHelper.ts b/frontend/src/pages/Letter/utils/letterHelper.ts index 47dec160..e38f23d3 100644 --- a/frontend/src/pages/Letter/utils/letterHelper.ts +++ b/frontend/src/pages/Letter/utils/letterHelper.ts @@ -16,7 +16,10 @@ function buildLetterUserMessage( const key = (loc ?? "oregon") as keyof typeof CITY_SELECT_OPTIONS; const selectedLocation = CITY_SELECT_OPTIONS[key]; if (selectedLocation === undefined) return null; - const locationString = formatLocation(selectedLocation.city, selectedLocation.state); + 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 e2a88d24..1109ba0f 100644 --- a/frontend/src/shared/constants/constants.ts +++ b/frontend/src/shared/constants/constants.ts @@ -8,7 +8,10 @@ interface CitySelectOptionType { label: string; } -const CITY_SELECT_OPTIONS: Record = { +const CITY_SELECT_OPTIONS: Record< + TOregonCity | "oregon" | "other", + CitySelectOptionType +> = { portland: { city: "portland", state: "or", diff --git a/frontend/src/shared/utils/formatLocation.ts b/frontend/src/shared/utils/formatLocation.ts index 7d6d0d19..84dc8da4 100644 --- a/frontend/src/shared/utils/formatLocation.ts +++ b/frontend/src/shared/utils/formatLocation.ts @@ -5,7 +5,10 @@ import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; * * @returns A display string like "Portland, OR", "OR", or "" if both are null. */ -function formatLocation(city: TOregonCity | null, state: TUsaState | null): string { +function formatLocation( + city: TOregonCity | null, + state: TUsaState | null, +): string { const cityDisplay = city ? city.charAt(0).toUpperCase() + city.slice(1) : null; From 0f1e438e42b969297b2c9bfd7f900e047ff50bad Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 01:57:17 -0800 Subject: [PATCH 03/18] Fixed formatting; Reduced frontend type files to two; Updated unit test; Updated Architecture.md --- Architecture.md | 5 ++--- backend/scripts/generate_types.py | 12 ++++-------- frontend/src/Letter.tsx | 2 +- frontend/src/contexts/HousingContext.tsx | 2 +- frontend/src/hooks/useMessages.tsx | 2 +- .../src/pages/Chat/components/InitializationForm.tsx | 2 +- frontend/src/pages/Chat/components/InputField.tsx | 2 +- frontend/src/pages/Chat/components/MessageWindow.tsx | 2 +- frontend/src/pages/Chat/utils/formHelper.ts | 2 +- frontend/src/pages/Chat/utils/streamHelper.ts | 2 +- frontend/src/tests/utils/formHelper.test.ts | 2 +- frontend/src/types/HousingTypes.ts | 11 ----------- frontend/src/types/LocationTypes.ts | 7 ++++++- 13 files changed, 21 insertions(+), 32 deletions(-) delete mode 100644 frontend/src/types/HousingTypes.ts diff --git a/Architecture.md b/Architecture.md index 7a0ca772..7fcadd2a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -76,7 +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 # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes,HousingTypes}.ts from Pydantic/StrEnum models +│ ├── generate_types.py # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes}.ts from Pydantic/StrEnum models │ └── documents/ # Source legal documents │ └── or/ # Oregon state laws │ ├── OAR54.txt # Oregon Administrative Rules @@ -465,8 +465,7 @@ frontend/ │ │ └── useLetterContent.tsx # State management for letter generation │ ├── types/ # Auto-generated TypeScript types — do not edit manually, re-run `make generate-types` │ │ ├── MessageTypes.ts # TResponseChunk union and chunk interfaces (from schema.py) -│ │ ├── LocationTypes.ts # TOregonCity and TUsaState string literal types (from location.py) -│ │ └── HousingTypes.ts # ILocation interface (from location.py) +│ │ └── LocationTypes.ts # TOregonCity, TUsaState, and ILocation (from location.py) │ ├── layouts/ # Layouts │ │ └── PageLayout.tsx # Layout for pages │ ├── pages/ diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index e8df0fc6..137396d4 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -1,7 +1,7 @@ """Generate frontend TypeScript types from backend Pydantic models and enums. Run with: uv run python scripts/generate_types.py ../frontend/src/types/ -Output: /MessageTypes.ts, /LocationTypes.ts, /HousingTypes.ts +Output: /MessageTypes.ts, /LocationTypes.ts """ import sys @@ -90,13 +90,9 @@ def make_file(blocks: list[str], exports: list[str], imports: str = "") -> str: ["TResponseChunk", *ts_names], ), "LocationTypes.ts": make_file( - [enum_to_ts_type(e) for e in LOCATION_ENUMS], - [f"T{e.__name__}" for e in LOCATION_ENUMS], - ), - "HousingTypes.ts": make_file( - [model_to_interface(Location)], - [f"I{Location.__name__}"], - imports='import type { TOregonCity, TUsaState } from "./LocationTypes";', + [enum_to_ts_type(e) for e in LOCATION_ENUMS] + + [model_to_interface(Location)], + [f"T{e.__name__}" for e in LOCATION_ENUMS] + [f"I{Location.__name__}"], ), } diff --git a/frontend/src/Letter.tsx b/frontend/src/Letter.tsx index 3e739179..a523f87c 100644 --- a/frontend/src/Letter.tsx +++ b/frontend/src/Letter.tsx @@ -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 type { ILocation } from "./types/HousingTypes"; +import type { ILocation } from "./types/LocationTypes"; import FeatureSnippet from "./shared/components/FeatureSnippet"; import clsx from "clsx"; diff --git a/frontend/src/contexts/HousingContext.tsx b/frontend/src/contexts/HousingContext.tsx index 87eb6738..264c1502 100644 --- a/frontend/src/contexts/HousingContext.tsx +++ b/frontend/src/contexts/HousingContext.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useMemo, useState } from "react"; import DOMPurify, { SANITIZE_USER_SETTINGS } from "../shared/utils/dompurify"; -import type { ILocation } from "../types/HousingTypes"; +import type { ILocation } from "../types/LocationTypes"; import type { TCitySelectKey, THousingType, diff --git a/frontend/src/hooks/useMessages.tsx b/frontend/src/hooks/useMessages.tsx index 0b7df216..81e2a70c 100644 --- a/frontend/src/hooks/useMessages.tsx +++ b/frontend/src/hooks/useMessages.tsx @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import type { AIMessage, HumanMessage } from "@langchain/core/messages"; -import type { ILocation } from "../types/HousingTypes"; +import type { ILocation } from "../types/LocationTypes"; /** * Chat message Type aligned with LangChain's message types diff --git a/frontend/src/pages/Chat/components/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index 2c746356..df1596a3 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -1,6 +1,6 @@ import { TChatMessage } from "../../../hooks/useMessages"; import BeaverIcon from "../../../shared/components/BeaverIcon"; -import type { ILocation } from "../../../types/HousingTypes"; +import type { ILocation } from "../../../types/LocationTypes"; import { formatLocation } from "../../../shared/utils/formatLocation"; import { useEffect, useState } from "react"; import { buildChatUserMessage } from "../utils/formHelper"; diff --git a/frontend/src/pages/Chat/components/InputField.tsx b/frontend/src/pages/Chat/components/InputField.tsx index 68ca6f9d..5a3c9416 100644 --- a/frontend/src/pages/Chat/components/InputField.tsx +++ b/frontend/src/pages/Chat/components/InputField.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; import { HumanMessage } from "@langchain/core/messages"; import { type TChatMessage } from "../../../hooks/useMessages"; -import type { ILocation } from "../../../types/HousingTypes"; +import type { ILocation } from "../../../types/LocationTypes"; import { streamText } from "../utils/streamHelper"; import useHousingContext from "../../../hooks/useHousingContext"; import clsx from "clsx"; diff --git a/frontend/src/pages/Chat/components/MessageWindow.tsx b/frontend/src/pages/Chat/components/MessageWindow.tsx index 73d0909b..31f2b9e2 100644 --- a/frontend/src/pages/Chat/components/MessageWindow.tsx +++ b/frontend/src/pages/Chat/components/MessageWindow.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import type { TChatMessage } from "../../../hooks/useMessages"; -import type { ILocation } from "../../../types/HousingTypes"; +import type { ILocation } from "../../../types/LocationTypes"; import InputField from "./InputField"; import MessageContent from "./MessageContent"; import ExportMessagesButton from "./ExportMessagesButton"; diff --git a/frontend/src/pages/Chat/utils/formHelper.ts b/frontend/src/pages/Chat/utils/formHelper.ts index 60d0cc5a..817bbb12 100644 --- a/frontend/src/pages/Chat/utils/formHelper.ts +++ b/frontend/src/pages/Chat/utils/formHelper.ts @@ -1,5 +1,5 @@ import { formatLocation } from "../../../shared/utils/formatLocation"; -import type { ILocation } from "../../../types/HousingTypes"; +import type { ILocation } from "../../../types/LocationTypes"; import type { THousingType, TTenantTopic, diff --git a/frontend/src/pages/Chat/utils/streamHelper.ts b/frontend/src/pages/Chat/utils/streamHelper.ts index b034bfb4..9b7d47b1 100644 --- a/frontend/src/pages/Chat/utils/streamHelper.ts +++ b/frontend/src/pages/Chat/utils/streamHelper.ts @@ -1,5 +1,5 @@ import { AIMessage } from "@langchain/core/messages"; -import type { ILocation } from "../../../types/HousingTypes"; +import type { ILocation } from "../../../types/LocationTypes"; import { type TChatMessage, type TUiMessage } from "../../../hooks/useMessages"; /** diff --git a/frontend/src/tests/utils/formHelper.test.ts b/frontend/src/tests/utils/formHelper.test.ts index 8199baec..6e34c0cb 100644 --- a/frontend/src/tests/utils/formHelper.test.ts +++ b/frontend/src/tests/utils/formHelper.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { buildChatUserMessage } from "../../pages/Chat/utils/formHelper"; -import type { ILocation } from "../../types/HousingTypes"; +import type { ILocation } from "../../types/LocationTypes"; describe("buildChatUserMessage", () => { it("builds message with all fields populated", () => { diff --git a/frontend/src/types/HousingTypes.ts b/frontend/src/types/HousingTypes.ts deleted file mode 100644 index 67e700ce..00000000 --- a/frontend/src/types/HousingTypes.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file is auto-generated by backend/scripts/generate_types.py. -// Do not edit manually — re-run `make generate-types`. - -import type { TOregonCity, TUsaState } from "./LocationTypes"; - -interface ILocation { - city: TOregonCity | null; - state: TUsaState | null; -} - -export type { ILocation }; diff --git a/frontend/src/types/LocationTypes.ts b/frontend/src/types/LocationTypes.ts index 5593736f..4e4053ea 100644 --- a/frontend/src/types/LocationTypes.ts +++ b/frontend/src/types/LocationTypes.ts @@ -5,4 +5,9 @@ type TOregonCity = "portland" | "eugene"; type TUsaState = "or" | "other"; -export type { TOregonCity, TUsaState }; +interface ILocation { + city: TOregonCity | null; + state: TUsaState | null; +} + +export type { TOregonCity, TUsaState, ILocation }; From 672f6b17786496187e4032b63d8fc61c023c9314 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 02:28:32 -0800 Subject: [PATCH 04/18] Added test for generate_types; Added CI check for generated type drift as suggested; Added type to CHUNK_MODELS; Have other return as null instead of OTHER; Updated unit tests --- .github/workflows/pr-check.yml | 31 ++++++++ Architecture.md | 2 +- backend/scripts/generate_types.py | 15 ++-- backend/tenantfirstaid/location.py | 2 +- backend/tests/test_generate_types.py | 71 +++++++++++++++++++ frontend/src/shared/utils/formatLocation.ts | 2 +- .../src/tests/utils/formatLocation.test.ts | 8 +++ 7 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_generate_types.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6f666151..de9ab2f2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -117,3 +117,34 @@ jobs: - name: Build frontend run: npm run build + + type-drift: + runs-on: ubuntu-latest + + permissions: + contents: 'read' + + steps: + - uses: actions/checkout@v6 + + - 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 + working-directory: backend + run: uv python install 3.13 + + - name: Sync dependencies + working-directory: backend + run: uv sync --dev + + - name: Regenerate types + working-directory: backend + run: make generate-types + + - name: Check for type drift + run: git diff --exit-code frontend/src/types/ diff --git a/Architecture.md b/Architecture.md index 7fcadd2a..c8140712 100644 --- a/Architecture.md +++ b/Architecture.md @@ -490,7 +490,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/ diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index 137396d4..c957c9db 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -15,7 +15,7 @@ from tenantfirstaid.location import Location, OregonCity, UsaState from tenantfirstaid.schema import ResponseChunk -CHUNK_MODELS = list(get_args(ResponseChunk)) +CHUNK_MODELS: list[type[BaseModel]] = list(get_args(ResponseChunk)) LOCATION_ENUMS: list[type[StrEnum]] = [OregonCity, UsaState] HEADER = ( @@ -63,13 +63,9 @@ def enum_to_ts_type(enum: type[StrEnum]) -> str: return f"type T{enum.__name__} = {members};" -def make_file(blocks: list[str], exports: list[str], imports: str = "") -> str: - """Assemble a TypeScript file from blocks, with optional imports.""" - parts = [HEADER] - if imports: - parts.append(imports) - parts.extend(blocks) - parts.append(f"export type {{ {', '.join(exports)} }};") +def make_file(blocks: list[str], exports: list[str]) -> str: + """Assemble a TypeScript file from blocks.""" + parts = [HEADER, *blocks, f"export type {{ {', '.join(exports)} }};"] return "\n\n".join(parts) + "\n" @@ -79,6 +75,9 @@ def make_file(blocks: list[str], exports: list[str], imports: str = "") -> str: sys.exit(1) types_dir = Path(sys.argv[1]) + if not types_dir.is_dir(): + print(f"Error: types_dir does not exist: {types_dir}") + sys.exit(1) ts_names = [f"I{m.__name__}" for m in CHUNK_MODELS] interfaces = [model_to_interface(m) for m in CHUNK_MODELS] diff --git a/backend/tenantfirstaid/location.py b/backend/tenantfirstaid/location.py index f1c84b9d..fa261283 100644 --- a/backend/tenantfirstaid/location.py +++ b/backend/tenantfirstaid/location.py @@ -64,7 +64,7 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState": class Location(BaseModel): - """Location data as received from the frontend request.""" + """City and state for a user's location. Used by generate_types.py to produce ILocation.""" city: OregonCity | None = None state: UsaState | None = None diff --git a/backend/tests/test_generate_types.py b/backend/tests/test_generate_types.py new file mode 100644 index 00000000..7f288371 --- /dev/null +++ b/backend/tests/test_generate_types.py @@ -0,0 +1,71 @@ +"""Tests for scripts/generate_types.py.""" + +import pytest + +from scripts.generate_types import ( + HEADER, + enum_to_ts_type, + make_file, + model_to_interface, + py_annotation_to_ts, +) +from tenantfirstaid.location import Location, OregonCity, UsaState + + +def test_py_annotation_to_ts_str(): + assert py_annotation_to_ts(str) == "string" + + +def test_py_annotation_to_ts_str_enum(): + assert py_annotation_to_ts(OregonCity) == "TOregonCity" + assert py_annotation_to_ts(UsaState) == "TUsaState" + + +def test_py_annotation_to_ts_literal(): + from typing import Literal + + assert py_annotation_to_ts(Literal["foo", "bar"]) == '"foo" | "bar"' + + +def test_py_annotation_to_ts_optional(): + from typing import Optional + + result = py_annotation_to_ts(Optional[str]) + assert "string" in result + assert "null" in result + + +def test_py_annotation_to_ts_unsupported(): + with pytest.raises(TypeError, match="Unsupported annotation"): + py_annotation_to_ts(int) + + +def test_enum_to_ts_type_oregon_city(): + result = enum_to_ts_type(OregonCity) + assert result == 'type TOregonCity = "portland" | "eugene";' + + +def test_enum_to_ts_type_usa_state(): + result = enum_to_ts_type(UsaState) + assert result == 'type TUsaState = "or" | "other";' + + +def test_model_to_interface_location(): + result = model_to_interface(Location) + assert "interface ILocation {" in result + assert "city:" in result + assert "state:" in result + assert "}" in result + + +def test_make_file_structure(): + result = make_file(["type Foo = string;"], ["Foo"]) + assert result.startswith(HEADER) + assert "type Foo = string;" in result + assert "export type { Foo };" in result + assert result.endswith("\n") + + +def test_make_file_multiple_exports(): + result = make_file(["type A = string;", "type B = string;"], ["A", "B"]) + assert "export type { A, B };" in result diff --git a/frontend/src/shared/utils/formatLocation.ts b/frontend/src/shared/utils/formatLocation.ts index 84dc8da4..86f539ef 100644 --- a/frontend/src/shared/utils/formatLocation.ts +++ b/frontend/src/shared/utils/formatLocation.ts @@ -12,7 +12,7 @@ function formatLocation( const cityDisplay = city ? city.charAt(0).toUpperCase() + city.slice(1) : null; - const stateDisplay = state?.toUpperCase() ?? null; + const stateDisplay = state && state !== "other" ? state.toUpperCase() : null; if (cityDisplay && stateDisplay) return `${cityDisplay}, ${stateDisplay}`; return cityDisplay || stateDisplay || ""; diff --git a/frontend/src/tests/utils/formatLocation.test.ts b/frontend/src/tests/utils/formatLocation.test.ts index 7e0f5fd5..ccac5779 100644 --- a/frontend/src/tests/utils/formatLocation.test.ts +++ b/frontend/src/tests/utils/formatLocation.test.ts @@ -17,4 +17,12 @@ describe("formatLocation", () => { it("returns empty string when both are null", () => { expect(formatLocation(null, null)).toBe(""); }); + + 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"); + }); }); From 6775cc8bc5eaadfab0e5513f289144e80fa02757 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 02:33:51 -0800 Subject: [PATCH 05/18] Fixed type for py_annotation_to_ts --- backend/scripts/generate_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index c957c9db..f5263f73 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -8,7 +8,7 @@ from enum import StrEnum from pathlib import Path from types import UnionType -from typing import Literal, Union, get_args, get_origin +from typing import Any, Literal, Union, get_args, get_origin from pydantic import BaseModel @@ -24,7 +24,7 @@ ) -def py_annotation_to_ts(annotation: type) -> str: +def py_annotation_to_ts(annotation: Any) -> str: """Convert a Python type annotation to a TypeScript type string. Supported annotations: str, Literal, StrEnum subclasses, X | None, Optional[X]. From 8ecd19f48634f231194cadeb8d1d6b28820a344b Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 02:37:30 -0800 Subject: [PATCH 06/18] Moving PR check to under backend-test --- .github/workflows/pr-check.yml | 36 +++++----------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index de9ab2f2..d4f0cf79 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -83,6 +83,11 @@ jobs: if: env.PR_FROM_FORK != 'true' run: uv run pytest -v -s -m "require_repo_secrets" + - name: Check for generated type drift + run: | + make generate-types + git diff --exit-code ../frontend/src/types/ + frontend-build: runs-on: ubuntu-latest @@ -117,34 +122,3 @@ jobs: - name: Build frontend run: npm run build - - type-drift: - runs-on: ubuntu-latest - - permissions: - contents: 'read' - - steps: - - uses: actions/checkout@v6 - - - 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 - working-directory: backend - run: uv python install 3.13 - - - name: Sync dependencies - working-directory: backend - run: uv sync --dev - - - name: Regenerate types - working-directory: backend - run: make generate-types - - - name: Check for type drift - run: git diff --exit-code frontend/src/types/ From 4cc61674a8877f5e8e4ccc6a0dc9fdefe1966126 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 02:54:12 -0800 Subject: [PATCH 07/18] Fixed issue with formHelper; Adjusted handleLocationChange logic; Adjusted unit test; Updated comment for Location class; Include comment for model_to_interface based on feedback suggestion --- backend/scripts/generate_types.py | 7 ++++++- backend/tenantfirstaid/location.py | 5 ++++- backend/tests/test_generate_types.py | 4 ++-- .../pages/Chat/components/InitializationForm.tsx | 16 ++++++++-------- frontend/src/pages/Chat/utils/formHelper.ts | 2 +- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index f5263f73..9f412669 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -49,7 +49,12 @@ def py_annotation_to_ts(annotation: Any) -> str: def model_to_interface(model: type[BaseModel]) -> str: - """Render a Pydantic BaseModel as a TypeScript interface.""" + """Render a Pydantic BaseModel as a TypeScript interface. + + All fields are rendered as required. Models with optional fields would need + field_info.is_required() checks to emit `field?: type` instead, e.g.: + lines.append(f" {field_name}{'?' if not field_info.is_required() else ''}: ...") + """ lines = [f"interface I{model.__name__} {{"] for field_name, field_info in model.model_fields.items(): lines.append(f" {field_name}: {py_annotation_to_ts(field_info.annotation)};") diff --git a/backend/tenantfirstaid/location.py b/backend/tenantfirstaid/location.py index fa261283..e5e0e6fa 100644 --- a/backend/tenantfirstaid/location.py +++ b/backend/tenantfirstaid/location.py @@ -64,7 +64,10 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState": class Location(BaseModel): - """City and state for a user's location. Used by generate_types.py to produce ILocation.""" + """City and state as sent by the frontend. Used by generate_types.py to produce ILocation. + + state=None is treated as UsaState.OTHER by the backend. + """ city: OregonCity | None = None state: UsaState | None = None diff --git a/backend/tests/test_generate_types.py b/backend/tests/test_generate_types.py index 7f288371..1a2a18eb 100644 --- a/backend/tests/test_generate_types.py +++ b/backend/tests/test_generate_types.py @@ -53,8 +53,8 @@ def test_enum_to_ts_type_usa_state(): def test_model_to_interface_location(): result = model_to_interface(Location) assert "interface ILocation {" in result - assert "city:" in result - assert "state:" in result + assert " city: TOregonCity | null;" in result + assert " state: TUsaState | null;" in result assert "}" in result diff --git a/frontend/src/pages/Chat/components/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index df1596a3..f26ade9d 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -58,14 +58,12 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { ); const handleLocationChange = (key: string | null) => { - handleCityChange(key as TCitySelectKey | null); + const typedKey = key as TCitySelectKey | 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, }); }; @@ -149,7 +147,9 @@ 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}.` + : ""}
    Date: Tue, 3 Mar 2026 03:03:25 -0800 Subject: [PATCH 08/18] Fixed unit test; Updated handleLocationChange logic to use ?? --- .../src/pages/Chat/components/InitializationForm.tsx | 4 ++-- frontend/src/tests/utils/formHelper.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Chat/components/InitializationForm.tsx b/frontend/src/pages/Chat/components/InitializationForm.tsx index f26ade9d..ebd386f0 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -62,8 +62,8 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { const selected = typedKey !== null ? CITY_SELECT_OPTIONS[typedKey] : null; handleCityChange(typedKey); handleHousingLocation({ - city: selected?.city || null, - state: selected?.state || null, + city: selected?.city ?? null, + state: selected?.state ?? null, }); }; diff --git a/frontend/src/tests/utils/formHelper.test.ts b/frontend/src/tests/utils/formHelper.test.ts index 6e34c0cb..7f96b20a 100644 --- a/frontend/src/tests/utils/formHelper.test.ts +++ b/frontend/src/tests/utils/formHelper.test.ts @@ -47,6 +47,16 @@ 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", From 8323430ee5415e1e16d350301abc55a9af9ec5e9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:05:28 +0000 Subject: [PATCH 09/18] Add direct test for non-Optional union in py_annotation_to_ts Co-authored-by: Ka Hung Lee --- backend/tests/test_generate_types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/tests/test_generate_types.py b/backend/tests/test_generate_types.py index 1a2a18eb..3bcb8052 100644 --- a/backend/tests/test_generate_types.py +++ b/backend/tests/test_generate_types.py @@ -35,6 +35,11 @@ def test_py_annotation_to_ts_optional(): assert "null" in result +def test_py_annotation_to_ts_union_without_none(): + result = py_annotation_to_ts(OregonCity | UsaState) + assert result == "TOregonCity | TUsaState" + + def test_py_annotation_to_ts_unsupported(): with pytest.raises(TypeError, match="Unsupported annotation"): py_annotation_to_ts(int) From fbba60e825dba70e0bed7edaefede6bbeb8c854f Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Tue, 3 Mar 2026 14:31:35 -0800 Subject: [PATCH 10/18] Simplified logic for formatLocation; Adjust unit tests --- frontend/src/shared/utils/formatLocation.ts | 3 +-- frontend/src/tests/utils/formatLocation.test.ts | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/frontend/src/shared/utils/formatLocation.ts b/frontend/src/shared/utils/formatLocation.ts index 86f539ef..89c71802 100644 --- a/frontend/src/shared/utils/formatLocation.ts +++ b/frontend/src/shared/utils/formatLocation.ts @@ -14,8 +14,7 @@ function formatLocation( : null; const stateDisplay = state && state !== "other" ? state.toUpperCase() : null; - if (cityDisplay && stateDisplay) return `${cityDisplay}, ${stateDisplay}`; - return cityDisplay || stateDisplay || ""; + return [cityDisplay, stateDisplay].filter(Boolean).join(", "); } export { formatLocation }; diff --git a/frontend/src/tests/utils/formatLocation.test.ts b/frontend/src/tests/utils/formatLocation.test.ts index ccac5779..0d488086 100644 --- a/frontend/src/tests/utils/formatLocation.test.ts +++ b/frontend/src/tests/utils/formatLocation.test.ts @@ -6,18 +6,10 @@ describe("formatLocation", () => { expect(formatLocation("portland", "or")).toBe("Portland, OR"); }); - it("capitalizes city with null state", () => { - expect(formatLocation("eugene", null)).toBe("Eugene"); - }); - it("uppercases state with null city", () => { expect(formatLocation(null, "or")).toBe("OR"); }); - it("returns empty string when both are null", () => { - expect(formatLocation(null, null)).toBe(""); - }); - it("omits state when state is 'other'", () => { expect(formatLocation(null, "other")).toBe(""); }); From 3fc5e1044ff809a60d1f5f218ee4a5f9b98cd673 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Wed, 4 Mar 2026 16:43:15 -0800 Subject: [PATCH 11/18] Updated Architecture.md; Including frontend types directory to .gitignore; Updated frontend npm script to include generate-types; Adjusted pr-check.yml to setup type generation --- .claude/CLAUDE.md | 12 +++++++++++- .github/workflows/pr-check.yml | 23 ++++++++++++++++++----- .gitignore | 3 +++ Architecture.md | 4 ++-- frontend/package.json | 3 ++- frontend/src/types/LocationTypes.ts | 13 ------------- frontend/src/types/MessageTypes.ts | 21 --------------------- 7 files changed, 36 insertions(+), 43 deletions(-) delete mode 100644 frontend/src/types/LocationTypes.ts delete mode 100644 frontend/src/types/MessageTypes.ts 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 d4f0cf79..a47984d8 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -83,11 +83,6 @@ jobs: if: env.PR_FROM_FORK != 'true' run: uv run pytest -v -s -m "require_repo_secrets" - - name: Check for generated type drift - run: | - make generate-types - git diff --exit-code ../frontend/src/types/ - frontend-build: runs-on: ubuntu-latest @@ -108,9 +103,27 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - name: Install uv + uses: astral-sh/setup-uv@7.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 c8140712..2beba82b 100644 --- a/Architecture.md +++ b/Architecture.md @@ -76,7 +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 # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes}.ts from Pydantic/StrEnum models +│ ├── generate_types.py # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes}.ts from Pydantic/StrEnum models (run via `make generate-types` or `npm run generate-types`) │ └── documents/ # Source legal documents │ └── or/ # Oregon state laws │ ├── OAR54.txt # Oregon Administrative Rules @@ -463,7 +463,7 @@ frontend/ │ │ ├── useMessages.tsx # Message handling logic │ │ ├── useHousingContext.tsx # Custom hook for housing context │ │ └── useLetterContent.tsx # State management for letter generation -│ ├── types/ # Auto-generated TypeScript types — do not edit manually, re-run `make generate-types` +│ ├── types/ # Auto-generated TypeScript types (gitignored) — do not edit manually, re-run `make generate-types` or `npm run generate-types` │ │ ├── MessageTypes.ts # TResponseChunk union and chunk interfaces (from schema.py) │ │ └── LocationTypes.ts # TOregonCity, TUsaState, and ILocation (from location.py) │ ├── layouts/ # Layouts diff --git a/frontend/package.json b/frontend/package.json index ff802c64..ce1d5ca6 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", diff --git a/frontend/src/types/LocationTypes.ts b/frontend/src/types/LocationTypes.ts deleted file mode 100644 index 4e4053ea..00000000 --- a/frontend/src/types/LocationTypes.ts +++ /dev/null @@ -1,13 +0,0 @@ -// This file is auto-generated by backend/scripts/generate_types.py. -// Do not edit manually — re-run `make generate-types`. - -type TOregonCity = "portland" | "eugene"; - -type TUsaState = "or" | "other"; - -interface ILocation { - city: TOregonCity | null; - state: TUsaState | null; -} - -export type { TOregonCity, TUsaState, ILocation }; diff --git a/frontend/src/types/MessageTypes.ts b/frontend/src/types/MessageTypes.ts deleted file mode 100644 index a563e6c1..00000000 --- a/frontend/src/types/MessageTypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This file is auto-generated by backend/scripts/generate_types.py. -// Do not edit manually — re-run `make generate-types`. - -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, ITextChunk, IReasoningChunk, ILetterChunk }; From c2927ea2f8193a9c6ae87cc4ad909c25bbfd658d Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Wed, 4 Mar 2026 16:45:45 -0800 Subject: [PATCH 12/18] Fixed typo --- .github/workflows/pr-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index a47984d8..103248fa 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -104,7 +104,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install uv - uses: astral-sh/setup-uv@7.3.0 + uses: astral-sh/setup-uv@v7.3.0 with: enable-cache: true cache-dependency-glob: "backend/uv.lock" From e691dcda1ae3390a4a357605be22907e1c30ee3a Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Wed, 4 Mar 2026 16:48:49 -0800 Subject: [PATCH 13/18] Updated makefile to create types directory for frontend in case it doesn't exist --- backend/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/Makefile b/backend/Makefile index f8d117f1..e3e512e2 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -40,6 +40,7 @@ test: uv.lock uv run pytest -v -s $(TEST_OPTIONS) generate-types: uv.lock + mkdir -p $(FRONTEND_DIR)/src/types $(PYTHON) run python scripts/generate_types.py $(FRONTEND_DIR)/src/types clean: From e3e8d683386b932ee75e492e7a54beb33e252996 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Wed, 4 Mar 2026 16:58:33 -0800 Subject: [PATCH 14/18] Sync uv.lock to 0.3.1 --- backend/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/uv.lock b/backend/uv.lock index 5cec7281..9ca97a42 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2805,7 +2805,7 @@ wheels = [ [[package]] name = "tenant-first-aid" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "flask" }, From d1a2245ab5f5235f3062a015b5c3f21a379de2f8 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Wed, 4 Mar 2026 17:02:39 -0800 Subject: [PATCH 15/18] Updated README with instructions on generate-types --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From 84e82e4ef56f6df7705715c1737b693bd48504a9 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Fri, 6 Mar 2026 15:44:02 -0800 Subject: [PATCH 16/18] Switched type codegen to pydantic2ts; Removed I/T prefixes from frontend types; Updated Architecture.md; Included python and npm packages for codegen pipeline --- Architecture.md | 5 +- backend/Makefile | 2 +- backend/pyproject.toml | 1 + backend/scripts/generate_types.py | 108 ++---------------- backend/tenantfirstaid/location.py | 2 +- backend/tests/test_generate_types.py | 76 ------------ backend/uv.lock | 14 +++ frontend/eslint.config.js | 2 +- frontend/package-lock.json | 103 ++++++++++++++--- frontend/package.json | 1 + frontend/src/Letter.tsx | 8 +- frontend/src/contexts/HousingContext.tsx | 44 +++---- frontend/src/hooks/useLetterContent.tsx | 8 +- frontend/src/hooks/useMessages.tsx | 16 +-- .../Chat/components/ExportMessagesButton.tsx | 4 +- .../pages/Chat/components/FeedbackModal.tsx | 4 +- .../Chat/components/InitializationForm.tsx | 22 ++-- .../src/pages/Chat/components/InputField.tsx | 8 +- .../pages/Chat/components/MessageContent.tsx | 14 +-- .../pages/Chat/components/MessageWindow.tsx | 10 +- frontend/src/pages/Chat/utils/exportHelper.ts | 8 +- .../src/pages/Chat/utils/feedbackHelper.ts | 8 +- frontend/src/pages/Chat/utils/formHelper.ts | 16 +-- frontend/src/pages/Chat/utils/streamHelper.ts | 18 +-- .../src/pages/Letter/utils/letterHelper.ts | 4 +- frontend/src/shared/constants/constants.ts | 14 +-- frontend/src/shared/utils/formatLocation.ts | 6 +- frontend/src/tests/components/Letter.test.tsx | 4 +- .../tests/components/MessageContent.test.tsx | 4 +- .../tests/components/MessageWindow.test.tsx | 4 +- .../src/tests/hooks/useLetterContent.test.tsx | 8 +- frontend/src/tests/utils/exportHelper.test.ts | 10 +- .../src/tests/utils/feedbackHelper.test.ts | 12 +- frontend/src/tests/utils/formHelper.test.ts | 8 +- frontend/src/tests/utils/streamHelper.test.ts | 16 +-- 35 files changed, 252 insertions(+), 340 deletions(-) delete mode 100644 backend/tests/test_generate_types.py diff --git a/Architecture.md b/Architecture.md index 2beba82b..4252c06e 100644 --- a/Architecture.md +++ b/Architecture.md @@ -76,7 +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 # Codegen: generates frontend/src/types/{MessageTypes,LocationTypes}.ts from Pydantic/StrEnum models (run via `make generate-types` or `npm run generate-types`) +│ ├── 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 @@ -464,8 +464,7 @@ frontend/ │ │ ├── useHousingContext.tsx # Custom hook for housing context │ │ └── useLetterContent.tsx # State management for letter generation │ ├── types/ # Auto-generated TypeScript types (gitignored) — do not edit manually, re-run `make generate-types` or `npm run generate-types` -│ │ ├── MessageTypes.ts # TResponseChunk union and chunk interfaces (from schema.py) -│ │ └── LocationTypes.ts # TOregonCity, TUsaState, and ILocation (from location.py) +│ │ └── models.ts # All exported types: ResponseChunk, Location, OregonCity, UsaState, chunk interfaces │ ├── layouts/ # Layouts │ │ └── PageLayout.tsx # Layout for pages │ ├── pages/ diff --git a/backend/Makefile b/backend/Makefile index e3e512e2..e3a473b2 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -41,7 +41,7 @@ test: uv.lock generate-types: uv.lock mkdir -p $(FRONTEND_DIR)/src/types - $(PYTHON) run python scripts/generate_types.py $(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 {} + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a5f86fec..20f95ce9 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", ] [tool.pytest.ini_options] diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index 9f412669..01727164 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -1,106 +1,14 @@ -"""Generate frontend TypeScript types from backend Pydantic models and enums. +"""Models exported to the frontend as TypeScript types. -Run with: uv run python scripts/generate_types.py ../frontend/src/types/ -Output: /MessageTypes.ts, /LocationTypes.ts +Used by pydantic2ts via `make generate-types`. All BaseModel subclasses +visible in this module's namespace are included in the generated output. """ -import sys -from enum import StrEnum -from pathlib import Path -from types import UnionType -from typing import Any, Literal, Union, get_args, get_origin +from pydantic import RootModel -from pydantic import BaseModel +from tenantfirstaid.location import Location +from tenantfirstaid.schema import LetterChunk, ReasoningChunk, TextChunk -from tenantfirstaid.location import Location, OregonCity, UsaState -from tenantfirstaid.schema import ResponseChunk -CHUNK_MODELS: list[type[BaseModel]] = list(get_args(ResponseChunk)) -LOCATION_ENUMS: list[type[StrEnum]] = [OregonCity, UsaState] - -HEADER = ( - "// This file is auto-generated by backend/scripts/generate_types.py.\n" - "// Do not edit manually — re-run `make generate-types`." -) - - -def py_annotation_to_ts(annotation: Any) -> str: - """Convert a Python type annotation to a TypeScript type string. - - Supported annotations: str, Literal, StrEnum subclasses, X | None, Optional[X]. - Extend this function if a new backend type is needed (e.g. list, int, bool). - """ - origin = get_origin(annotation) - args = get_args(annotation) - - if origin is Literal: - return " | ".join(f'"{a}"' for a in args) - if annotation is str: - return "string" - if isinstance(annotation, type) and issubclass(annotation, StrEnum): - return f"T{annotation.__name__}" - if origin in (UnionType, Union): - ts_parts = [py_annotation_to_ts(a) for a in args if a is not type(None)] - if type(None) in args: - ts_parts.append("null") - return " | ".join(ts_parts) - - raise TypeError(f"Unsupported annotation for TypeScript codegen: {annotation!r}") - - -def model_to_interface(model: type[BaseModel]) -> str: - """Render a Pydantic BaseModel as a TypeScript interface. - - All fields are rendered as required. Models with optional fields would need - field_info.is_required() checks to emit `field?: type` instead, e.g.: - lines.append(f" {field_name}{'?' if not field_info.is_required() else ''}: ...") - """ - lines = [f"interface I{model.__name__} {{"] - for field_name, field_info in model.model_fields.items(): - lines.append(f" {field_name}: {py_annotation_to_ts(field_info.annotation)};") - lines.append("}") - return "\n".join(lines) - - -def enum_to_ts_type(enum: type[StrEnum]) -> str: - """Render a StrEnum as a TypeScript type alias.""" - members = " | ".join(f'"{e.value}"' for e in enum) - return f"type T{enum.__name__} = {members};" - - -def make_file(blocks: list[str], exports: list[str]) -> str: - """Assemble a TypeScript file from blocks.""" - parts = [HEADER, *blocks, f"export type {{ {', '.join(exports)} }};"] - return "\n\n".join(parts) + "\n" - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} ") - sys.exit(1) - - types_dir = Path(sys.argv[1]) - if not types_dir.is_dir(): - print(f"Error: types_dir does not exist: {types_dir}") - sys.exit(1) - - ts_names = [f"I{m.__name__}" for m in CHUNK_MODELS] - interfaces = [model_to_interface(m) for m in CHUNK_MODELS] - union_alias = f"type TResponseChunk = {' | '.join(ts_names)};" - - files = { - "MessageTypes.ts": make_file( - interfaces + [union_alias], - ["TResponseChunk", *ts_names], - ), - "LocationTypes.ts": make_file( - [enum_to_ts_type(e) for e in LOCATION_ENUMS] - + [model_to_interface(Location)], - [f"T{e.__name__}" for e in LOCATION_ENUMS] + [f"I{Location.__name__}"], - ), - } - - for filename, content in files.items(): - path = types_dir / filename - path.write_text(content) - print(f"Written to {path}") +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 e5e0e6fa..02d0ab7c 100644 --- a/backend/tenantfirstaid/location.py +++ b/backend/tenantfirstaid/location.py @@ -64,7 +64,7 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState": class Location(BaseModel): - """City and state as sent by the frontend. Used by generate_types.py to produce ILocation. + """City and state as sent by the frontend. state=None is treated as UsaState.OTHER by the backend. """ diff --git a/backend/tests/test_generate_types.py b/backend/tests/test_generate_types.py deleted file mode 100644 index 3bcb8052..00000000 --- a/backend/tests/test_generate_types.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for scripts/generate_types.py.""" - -import pytest - -from scripts.generate_types import ( - HEADER, - enum_to_ts_type, - make_file, - model_to_interface, - py_annotation_to_ts, -) -from tenantfirstaid.location import Location, OregonCity, UsaState - - -def test_py_annotation_to_ts_str(): - assert py_annotation_to_ts(str) == "string" - - -def test_py_annotation_to_ts_str_enum(): - assert py_annotation_to_ts(OregonCity) == "TOregonCity" - assert py_annotation_to_ts(UsaState) == "TUsaState" - - -def test_py_annotation_to_ts_literal(): - from typing import Literal - - assert py_annotation_to_ts(Literal["foo", "bar"]) == '"foo" | "bar"' - - -def test_py_annotation_to_ts_optional(): - from typing import Optional - - result = py_annotation_to_ts(Optional[str]) - assert "string" in result - assert "null" in result - - -def test_py_annotation_to_ts_union_without_none(): - result = py_annotation_to_ts(OregonCity | UsaState) - assert result == "TOregonCity | TUsaState" - - -def test_py_annotation_to_ts_unsupported(): - with pytest.raises(TypeError, match="Unsupported annotation"): - py_annotation_to_ts(int) - - -def test_enum_to_ts_type_oregon_city(): - result = enum_to_ts_type(OregonCity) - assert result == 'type TOregonCity = "portland" | "eugene";' - - -def test_enum_to_ts_type_usa_state(): - result = enum_to_ts_type(UsaState) - assert result == 'type TUsaState = "or" | "other";' - - -def test_model_to_interface_location(): - result = model_to_interface(Location) - assert "interface ILocation {" in result - assert " city: TOregonCity | null;" in result - assert " state: TUsaState | null;" in result - assert "}" in result - - -def test_make_file_structure(): - result = make_file(["type Foo = string;"], ["Foo"]) - assert result.startswith(HEADER) - assert "type Foo = string;" in result - assert "export type { Foo };" in result - assert result.endswith("\n") - - -def test_make_file_multiple_exports(): - result = make_file(["type A = string;", "type B = string;"], ["A", "B"]) - assert "export type { A, B };" in result diff --git a/backend/uv.lock b/backend/uv.lock index 9ca97a42..e14d5ad0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2288,6 +2288,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" @@ -2833,6 +2845,7 @@ dev = [ { name = "mypy" }, { name = "openevals" }, { name = "polars" }, + { name = "pydantic-to-typescript" }, { name = "pyrefly" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2871,6 +2884,7 @@ dev = [ { name = "mypy", specifier = ">=1.16.1" }, { name = "openevals", specifier = ">=0.1.2" }, { 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 ce1d5ca6..f82717b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,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 a523f87c..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 type { ILocation } from "./types/LocationTypes"; +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 264c1502..47d7c6a7 100644 --- a/frontend/src/contexts/HousingContext.tsx +++ b/frontend/src/contexts/HousingContext.tsx @@ -1,57 +1,57 @@ import { createContext, useCallback, useMemo, useState } from "react"; import DOMPurify, { SANITIZE_USER_SETTINGS } from "../shared/utils/dompurify"; -import type { ILocation } from "../types/LocationTypes"; +import type { Location } from "../types/models"; import type { - TCitySelectKey, - THousingType, - TTenantTopic, + CitySelectKey, + HousingType, + TenantTopic, } from "../shared/constants/constants"; -export interface IHousingContextType { - housingLocation: ILocation; - city: TCitySelectKey | null; - housingType: THousingType | null; - tenantTopic: TTenantTopic | null; +export interface HousingContextType { + housingLocation: Location; + city: CitySelectKey | null; + housingType: HousingType | null; + tenantTopic: TenantTopic | null; issueDescription: string; - handleHousingLocation: ({ city, state }: ILocation) => void; - handleCityChange: (option: TCitySelectKey | null) => void; - handleHousingChange: (option: THousingType | null) => void; - handleTenantTopic: (option: TTenantTopic | 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: TCitySelectKey | null) => { + const handleCityChange = useCallback((option: CitySelectKey | null) => { setCity(option); }, []); - const handleHousingChange = useCallback((option: THousingType | null) => { + const handleHousingChange = useCallback((option: HousingType | null) => { setHousingType(option); }, []); - const handleTenantTopic = useCallback((option: TTenantTopic | 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 81e2a70c..6853af1a 100644 --- a/frontend/src/hooks/useMessages.tsx +++ b/frontend/src/hooks/useMessages.tsx @@ -1,16 +1,16 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import type { AIMessage, HumanMessage } from "@langchain/core/messages"; -import type { ILocation } from "../types/LocationTypes"; +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; @@ -37,8 +37,8 @@ export function deserializeAiMessage(text: string): string { } async function addNewMessage( - messages: TChatMessage[], - { city, state }: ILocation, + messages: ChatMessage[], + { city, state }: Location, ) { const serializedMsg = messages.map((msg) => ({ role: msg.type, @@ -59,13 +59,13 @@ 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 }: ILocation) => { + 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 }); 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 ebd386f0..02df435d 100644 --- a/frontend/src/pages/Chat/components/InitializationForm.tsx +++ b/frontend/src/pages/Chat/components/InitializationForm.tsx @@ -1,6 +1,6 @@ -import { TChatMessage } from "../../../hooks/useMessages"; +import { ChatMessage } from "../../../hooks/useMessages"; import BeaverIcon from "../../../shared/components/BeaverIcon"; -import type { ILocation } from "../../../types/LocationTypes"; +import type { Location } from "../../../types/models"; import { formatLocation } from "../../../shared/utils/formatLocation"; import { useEffect, useState } from "react"; import { buildChatUserMessage } from "../utils/formHelper"; @@ -14,9 +14,9 @@ import { HOUSING_OPTIONS, LETTERABLE_TOPIC_OPTIONS, NONLETTERABLE_TOPIC_OPTIONS, - type TCitySelectKey, - type THousingType, - type TTenantTopic, + type CitySelectKey, + type HousingType, + type TenantTopic, } from "../../../shared/constants/constants"; import { scrollToTop } from "../../../shared/utils/scrolling"; import AutoExpandText from "./AutoExpandText"; @@ -25,13 +25,13 @@ import { HumanMessage } from "@langchain/core/messages"; const NONLETTERABLE_TOPICS = Object.keys( NONLETTERABLE_TOPIC_OPTIONS, -) as TTenantTopic[]; +) as TenantTopic[]; interface Props { addMessage: ( - args: ILocation, + args: Location, ) => Promise | undefined>; - setMessages: React.Dispatch>; + setMessages: React.Dispatch>; } /** @@ -58,7 +58,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { ); const handleLocationChange = (key: string | null) => { - const typedKey = key as TCitySelectKey | null; + const typedKey = key as CitySelectKey | null; const selected = typedKey !== null ? CITY_SELECT_OPTIONS[typedKey] : null; handleCityChange(typedKey); handleHousingLocation({ @@ -157,7 +157,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { value={housingType || ""} description="Select your housing type" handleFunction={(option) => - handleHousingChange(option as THousingType | null) + handleHousingChange(option as HousingType | null) } > {HOUSING_OPTIONS.map((option) => ( @@ -172,7 +172,7 @@ export default function InitializationForm({ addMessage, setMessages }: Props) { value={tenantTopic || ""} description="Select your topic" handleFunction={(option) => - handleTenantTopic(option as TTenantTopic | null) + handleTenantTopic(option as TenantTopic | null) } > diff --git a/frontend/src/pages/Chat/components/InputField.tsx b/frontend/src/pages/Chat/components/InputField.tsx index 5a3c9416..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 { ILocation } from "../../../types/LocationTypes"; +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: ILocation, + args: Location, ) => Promise | undefined>; - setMessages: React.Dispatch>; + 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 31f2b9e2..bece0ad1 100644 --- a/frontend/src/pages/Chat/components/MessageWindow.tsx +++ b/frontend/src/pages/Chat/components/MessageWindow.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import type { TChatMessage } from "../../../hooks/useMessages"; -import type { ILocation } from "../../../types/LocationTypes"; +import type { ChatMessage } from "../../../hooks/useMessages"; +import type { Location } from "../../../types/models"; import InputField from "./InputField"; import MessageContent from "./MessageContent"; import ExportMessagesButton from "./ExportMessagesButton"; @@ -10,11 +10,11 @@ import { useLocation } from "react-router-dom"; import clsx from "clsx"; interface Props { - messages: TChatMessage[]; + messages: ChatMessage[]; addMessage: ( - args: ILocation, + args: Location, ) => Promise | undefined>; - setMessages: React.Dispatch>; + 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 4f20e1d5..f6b89ea9 100644 --- a/frontend/src/pages/Chat/utils/formHelper.ts +++ b/frontend/src/pages/Chat/utils/formHelper.ts @@ -1,11 +1,11 @@ import { formatLocation } from "../../../shared/utils/formatLocation"; -import type { ILocation } from "../../../types/LocationTypes"; +import type { Location } from "../../../types/models"; import type { - THousingType, - TTenantTopic, + HousingType, + TenantTopic, } from "../../../shared/constants/constants"; -interface IChatFormReturnType { +interface ChatFormReturnType { userMessage: string; } @@ -19,11 +19,11 @@ interface IChatFormReturnType { * @returns Object containing the formatted user message */ function buildChatUserMessage( - loc: ILocation, - housingType: THousingType | null, - tenantTopic: TTenantTopic | null, + loc: Location, + housingType: HousingType | null, + tenantTopic: TenantTopic | null, issueDescription: string, -): IChatFormReturnType { +): ChatFormReturnType { const locationString = formatLocation(loc.city, loc.state); const promptParts = [ diff --git a/frontend/src/pages/Chat/utils/streamHelper.ts b/frontend/src/pages/Chat/utils/streamHelper.ts index 9b7d47b1..2f4c9a1f 100644 --- a/frontend/src/pages/Chat/utils/streamHelper.ts +++ b/frontend/src/pages/Chat/utils/streamHelper.ts @@ -1,16 +1,16 @@ import { AIMessage } from "@langchain/core/messages"; -import type { ILocation } from "../../../types/LocationTypes"; -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 { +export interface StreamTextOptions { addMessage: ( - args: ILocation, + args: Location, ) => Promise | undefined>; - setMessages: React.Dispatch>; - housingLocation: ILocation; + setMessages: React.Dispatch>; + housingLocation: Location; setIsLoading?: React.Dispatch>; } @@ -26,7 +26,7 @@ async function streamText({ setMessages, housingLocation, setIsLoading, -}: IStreamTextOptions): Promise { +}: StreamTextOptions): Promise { const botMessageId = (Date.now() + 1).toString(); setIsLoading?.(true); @@ -41,7 +41,7 @@ async function streamText({ 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, @@ -87,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 e38f23d3..b35ac6ea 100644 --- a/frontend/src/pages/Letter/utils/letterHelper.ts +++ b/frontend/src/pages/Letter/utils/letterHelper.ts @@ -4,7 +4,7 @@ import { } from "../../../shared/constants/constants"; import { formatLocation } from "../../../shared/utils/formatLocation"; -interface IBuildLetterReturnType { +interface BuildLetterReturnType { userMessage: string; selectedLocation: CitySelectOptionType; } @@ -12,7 +12,7 @@ interface IBuildLetterReturnType { function buildLetterUserMessage( org: string | undefined, loc: string | undefined, -): IBuildLetterReturnType | null { +): BuildLetterReturnType | null { const key = (loc ?? "oregon") as keyof typeof CITY_SELECT_OPTIONS; const selectedLocation = CITY_SELECT_OPTIONS[key]; if (selectedLocation === undefined) return null; diff --git a/frontend/src/shared/constants/constants.ts b/frontend/src/shared/constants/constants.ts index 1109ba0f..803b3302 100644 --- a/frontend/src/shared/constants/constants.ts +++ b/frontend/src/shared/constants/constants.ts @@ -1,15 +1,15 @@ -import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; +import type { OregonCity, UsaState } from "../../types/models"; const CONTACT_EMAIL = "michael@qiu-qiulaw.com"; interface CitySelectOptionType { - city: TOregonCity | null; - state: TUsaState | null; + city: OregonCity | null; + state: UsaState | null; label: string; } const CITY_SELECT_OPTIONS: Record< - TOregonCity | "oregon" | "other", + OregonCity | "oregon" | "other", CitySelectOptionType > = { portland: { @@ -165,6 +165,6 @@ export { export type { CitySelectOptionType }; -export type TCitySelectKey = keyof typeof CITY_SELECT_OPTIONS; -export type THousingType = (typeof HOUSING_OPTIONS)[number]; -export type TTenantTopic = keyof typeof ALL_TOPIC_OPTIONS; +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 index 89c71802..6c39f874 100644 --- a/frontend/src/shared/utils/formatLocation.ts +++ b/frontend/src/shared/utils/formatLocation.ts @@ -1,4 +1,4 @@ -import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; +import type { OregonCity, UsaState } from "../../types/models"; /** * Formats a city and state into a human-readable location string. @@ -6,8 +6,8 @@ import type { TOregonCity, TUsaState } from "../../types/LocationTypes"; * @returns A display string like "Portland, OR", "OR", or "" if both are null. */ function formatLocation( - city: TOregonCity | null, - state: TUsaState | null, + city: OregonCity | null | undefined, + state: UsaState | null | undefined, ): string { const cityDisplay = city ? city.charAt(0).toUpperCase() + city.slice(1) 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 7f96b20a..033d2803 100644 --- a/frontend/src/tests/utils/formHelper.test.ts +++ b/frontend/src/tests/utils/formHelper.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from "vitest"; import { buildChatUserMessage } from "../../pages/Chat/utils/formHelper"; -import type { ILocation } from "../../types/LocationTypes"; +import type { Location } from "../../types/models"; describe("buildChatUserMessage", () => { it("builds message with all fields populated", () => { - const location: ILocation = { + const location: Location = { city: "portland", state: "or", }; @@ -28,7 +28,7 @@ describe("buildChatUserMessage", () => { }); it("handles null city gracefully", () => { - const location: ILocation = { + const location: Location = { city: null, state: "or", }; @@ -58,7 +58,7 @@ describe("buildChatUserMessage", () => { }); it("includes all prompt parts", () => { - const location: ILocation = { + const location: Location = { city: "portland", state: "or", }; diff --git a/frontend/src/tests/utils/streamHelper.test.ts b/frontend/src/tests/utils/streamHelper.test.ts index 05993a90..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( @@ -54,7 +54,7 @@ describe("streamText", () => { setMessages: mockSetMessages, housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(result).toBe(true); expect(mockAddMessage).toHaveBeenCalledWith({ @@ -81,7 +81,7 @@ describe("streamText", () => { setMessages: mockSetMessages, housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); const calls = mockSetMessages.mock.calls; const updateCall = calls[calls.length - 1][0]; @@ -106,7 +106,7 @@ describe("streamText", () => { setMessages: mockSetMessages, housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(mockSetIsLoading).toHaveBeenCalledWith(false); expect(console.error).toHaveBeenCalledWith("Error:", expect.any(Error)); @@ -134,7 +134,7 @@ describe("streamText", () => { setMessages: mockSetMessages, housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); // 1 initial + 2 chunk updates expect(mockSetMessages).toHaveBeenCalledTimes(3); @@ -163,7 +163,7 @@ describe("streamText", () => { setMessages: mockSetMessages, housingLocation: { city: "portland", state: "or" }, setIsLoading: mockSetIsLoading, - } as IStreamTextOptions); + } as StreamTextOptions); expect(result).toBe(true); expect(mockSetMessages).toHaveBeenCalledTimes(3); // 1 initial + 2 chunk updates @@ -186,13 +186,13 @@ describe("streamText", () => { setMessages: mockSetMessages, 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([ From ec9d2af389f3a682001f75a15d48b68531596adb Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Fri, 6 Mar 2026 15:50:20 -0800 Subject: [PATCH 17/18] Fixed unit test for generate_types.py --- backend/scripts/generate_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/generate_types.py b/backend/scripts/generate_types.py index 01727164..6b23af86 100644 --- a/backend/scripts/generate_types.py +++ b/backend/scripts/generate_types.py @@ -6,7 +6,7 @@ from pydantic import RootModel -from tenantfirstaid.location import Location +from tenantfirstaid.location import Location # noqa: F401 from tenantfirstaid.schema import LetterChunk, ReasoningChunk, TextChunk From bd489512dc1bd216728cc703f95adf343c7e6007 Mon Sep 17 00:00:00 2001 From: Ka Hung Lee Date: Fri, 6 Mar 2026 16:09:23 -0800 Subject: [PATCH 18/18] Updated Architecture.md --- Architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Architecture.md b/Architecture.md index 4252c06e..a6002223 100644 --- a/Architecture.md +++ b/Architecture.md @@ -510,7 +510,7 @@ frontend/ │ │ └── utils/ │ │ ├── scrolling.ts # Helper function for window scrolling │ │ ├── dompurify.ts # Helper function for sanitizing text -│ │ └── formatLocation.ts # Formats TOregonCity/TUsaState into a display string (e.g. "Portland, OR") +│ │ └── 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