Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,24 @@ 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
npm run lint
npx run format
```

2. Build frontend code
2. Build frontend code (automatically generates types first)
```bash
npm run build
```
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,27 @@ jobs:
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install uv
uses: astral-sh/setup-uv@v7.3.0
with:
enable-cache: true
cache-dependency-glob: "backend/uv.lock"
version: "0.10.0"

- name: Set up Python
run: uv python install 3.13
working-directory: backend

- name: Sync backend dependencies
run: uv sync
working-directory: backend

- name: Install dependencies
run: npm ci

- name: Generate types
run: npm run generate-types

- name: Run linting checks
run: npm run lint

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ dist/
.vite/
*.log

# Auto-generated frontend types (run `make generate-types` to regenerate)
frontend/src/types/

# Coverage reports
.coverage
htmlcov/
Expand Down
19 changes: 10 additions & 9 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}.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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -465,8 +463,9 @@ frontend/
│ │ ├── useMessages.tsx # Message handling logic
│ │ ├── useHousingContext.tsx # Custom hook for housing context
│ │ └── useLetterContent.tsx # State management for letter generation
│ ├── types/
│ │ └── MessageTypes.ts # TypeScript types mirroring backend schema (TResponseChunk, etc.)
│ ├── types/ # Auto-generated TypeScript types (gitignored) — do not edit manually, re-run `make generate-types` or `npm run generate-types`
│ │ ├── MessageTypes.ts # TResponseChunk union and chunk interfaces (from schema.py)
│ │ └── LocationTypes.ts # TOregonCity, TUsaState, and ILocation (from location.py)
│ ├── layouts/ # Layouts
│ │ └── PageLayout.tsx # Layout for pages
│ ├── pages/
Expand All @@ -491,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/
Expand All @@ -511,7 +510,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
Expand All @@ -536,7 +536,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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -38,6 +39,10 @@ typecheck-pyrefly: uv.lock
test: uv.lock
uv run pytest -v -s $(TEST_OPTIONS)

generate-types: uv.lock
mkdir -p $(FRONTEND_DIR)/src/types
$(PYTHON) run python scripts/generate_types.py $(FRONTEND_DIR)/src/types

clean:
find . -type d -name '__pycache__' -exec rm -r {} +
rm -rf dist build *.egg-info
Expand Down
106 changes: 106 additions & 0 deletions backend/scripts/generate_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Generate frontend TypeScript types from backend Pydantic models and enums.

Run with: uv run python scripts/generate_types.py ../frontend/src/types/
Output: <types_dir>/MessageTypes.ts, <types_dir>/LocationTypes.ts
"""

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 BaseModel

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]} <types_dir>")
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}")
11 changes: 11 additions & 0 deletions backend/tenantfirstaid/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -62,6 +63,16 @@ def from_maybe_str(cls, s: Optional[str] = None) -> "UsaState":
return state


class Location(BaseModel):
"""City and state as sent by the frontend. 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


class TFAAgentStateSchema(AgentState):
state: UsaState
city: Optional[OregonCity]
76 changes: 76 additions & 0 deletions backend/tests/test_generate_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""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
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/Letter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/LocationTypes";
import FeatureSnippet from "./shared/components/FeatureSnippet";
import clsx from "clsx";

Expand Down
Loading