Skip to content

Commit 3c3d995

Browse files
committed
backend + precommit
1 parent 670859a commit 3c3d995

File tree

13 files changed

+1275
-454
lines changed

13 files changed

+1275
-454
lines changed

.pre-commit-config.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
repos:
2+
# ── Backend (Python) ──────────────────────────────────────────────
3+
- repo: https://github.com/astral-sh/ruff-pre-commit
4+
rev: v0.15.4
5+
hooks:
6+
- id: ruff
7+
name: "ruff lint"
8+
args: [--fix]
9+
files: ^backend/
10+
- id: ruff-format
11+
name: "ruff format"
12+
files: ^backend/
13+
14+
# ── Backend mypy + Frontend ────────────────────────────────────────
15+
- repo: local
16+
hooks:
17+
- id: mypy
18+
name: "mypy"
19+
entry: bash -c 'cd backend && uv run mypy .'
20+
language: system
21+
files: ^backend/
22+
pass_filenames: false
23+
24+
- id: eslint
25+
name: "eslint"
26+
entry: pnpm eslint --fix
27+
language: system
28+
files: \.(ts|tsx)$
29+
exclude: ^backend/
30+
31+
- id: prettier
32+
name: "prettier"
33+
entry: pnpm prettier --write --ignore-unknown
34+
language: system
35+
files: \.(ts|tsx|js|jsx|json|css)$
36+
exclude: ^backend/

backend/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
GEMINI_API_KEY=""
1+
GEMINI_API_KEY=""
2+
GOOGLE_CLIENT_ID=""
3+
JWT_SECRET=""
4+
DATABASE_URL=""

backend/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
Here lies the glorious backend.
22

3-
How to run:
43

4+
Setup:
5+
```
6+
uv sync --all-groups
7+
uv run pre-commit install
8+
```
9+
10+
Run:
511
```
6-
uv sync
712
uv run main.py
813
```

backend/ai/__init__.py

Whitespace-only changes.

backend/ai/routes.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
from typing import Any
3+
4+
from fastapi import APIRouter, HTTPException
5+
from google import genai
6+
from pydantic import BaseModel, ConfigDict
7+
8+
from ai.schema import FunctionCallResponse
9+
10+
router = APIRouter(tags=["ai"])
11+
12+
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
13+
gemini_client: genai.Client = genai.Client(api_key=GEMINI_API_KEY)
14+
15+
16+
class Message(BaseModel):
17+
# Be permissive with incoming payloads from the frontend
18+
model_config = ConfigDict(extra="ignore")
19+
20+
message: str # the full user message
21+
mentioned_scrubber_ids: list[str] | None = None # scrubber ids mentioned via '@'
22+
# Accept any shape for resilience; backend does not mutate these
23+
timeline_state: dict[str, Any] | None = None # current timeline state
24+
mediabin_items: list[dict[str, Any]] | None = None # current media bin
25+
chat_history: list[dict[str, Any]] | None = (
26+
None # prior turns: [{"role":"user"|"assistant","content":"..."}]
27+
)
28+
29+
30+
@router.post("/ai")
31+
async def process_ai_message(request: Message) -> FunctionCallResponse:
32+
try:
33+
response = gemini_client.models.generate_content(
34+
model="gemini-2.5-flash",
35+
contents=f"""
36+
You are Kimu, an AI assistant inside a video editor. You can decide to either:
37+
- call ONE tool from the provided schema when the user explicitly asks for an editing action, or
38+
- return a short friendly assistant_message when no concrete action is needed (e.g., greetings, small talk, clarifying questions).
39+
40+
Strictly follow:
41+
- If the user's message does not clearly request an editing action, set function_call to null and include an assistant_message.
42+
- Only produce a function_call when it is safe and unambiguous to execute.
43+
44+
Inference rules:
45+
- Assume a single active timeline; do NOT require a timeline_id.
46+
- Tracks are named like "track-1", but when the user says "track 1" they mean number 1.
47+
- Use pixels_per_second=100 by default if not provided.
48+
- When the user names media like "twitter" or "twitter header", map that to the closest media in the media bin by name substring match.
49+
- Prefer LLMAddScrubberByName when the user specifies a name, track number, and time in seconds.
50+
- If the user asks to remove scrubbers in a specific track, call LLMDeleteScrubbersInTrack with that track number.
51+
52+
Conversation so far (oldest first): {request.chat_history}
53+
54+
User message: {request.message}
55+
Mentioned scrubber ids: {request.mentioned_scrubber_ids}
56+
Timeline state: {request.timeline_state}
57+
Media bin items: {request.mediabin_items}
58+
""",
59+
config={
60+
"response_mime_type": "application/json",
61+
"response_schema": FunctionCallResponse,
62+
},
63+
)
64+
65+
return FunctionCallResponse.model_validate(response.parsed)
66+
except Exception as e:
67+
raise HTTPException(status_code=500, detail=str(e)) from e
Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,20 @@ class TextProperties(BaseSchema):
1919

2020
class BaseScrubber(BaseSchema):
2121
id: str = Field(description="Unique identifier for the scrubber")
22-
mediaType: Literal["video", "image", "audio", "text"] = Field(description="Type of media")
23-
mediaUrlLocal: str | None = Field(description="Local URL for the media file", default=None)
24-
mediaUrlRemote: str | None = Field(description="Remote URL for the media file", default=None)
22+
mediaType: Literal["video", "image", "audio", "text"] = Field(
23+
description="Type of media"
24+
)
25+
mediaUrlLocal: str | None = Field(
26+
description="Local URL for the media file", default=None
27+
)
28+
mediaUrlRemote: str | None = Field(
29+
description="Remote URL for the media file", default=None
30+
)
2531
media_width: int = Field(description="Width of the media in pixels")
2632
media_height: int = Field(description="Height of the media in pixels")
27-
text: TextProperties | None = Field(description="Text properties if mediaType is text", default=None)
33+
text: TextProperties | None = Field(
34+
description="Text properties if mediaType is text", default=None
35+
)
2836

2937

3038
class MediaBinItem(BaseScrubber):
@@ -36,18 +44,22 @@ class ScrubberState(MediaBinItem):
3644
left: int = Field(description="Left position in pixels on the timeline")
3745
y: int = Field(description="Track position (0-based index)")
3846
width: int = Field(description="Width of the scrubber in pixels")
39-
47+
4048
# Player properties
4149
left_player: int = Field(description="Left position in the player view")
4250
top_player: int = Field(description="Top position in the player view")
4351
width_player: int = Field(description="Width in the player view")
4452
height_player: int = Field(description="Height in the player view")
45-
is_dragging: bool = Field(description="Whether the scrubber is currently being dragged")
53+
is_dragging: bool = Field(
54+
description="Whether the scrubber is currently being dragged"
55+
)
4656

4757

4858
class TrackState(BaseSchema):
4959
id: str = Field(description="Unique identifier for the track")
50-
scrubbers: list[ScrubberState] = Field(description="List of scrubbers on this track")
60+
scrubbers: list[ScrubberState] = Field(
61+
description="List of scrubbers on this track"
62+
)
5163

5264

5365
class TimelineState(BaseSchema):
@@ -81,19 +93,33 @@ class LLMAddScrubberByNameArgs(BaseSchema):
8193
function_name: Literal["LLMAddScrubberByName"] = Field(
8294
description="The name of the function to call"
8395
)
84-
scrubber_name: str = Field(description="The partial or full name of the media to add")
96+
scrubber_name: str = Field(
97+
description="The partial or full name of the media to add"
98+
)
8599
track_number: int = Field(description="1-based track number to add to")
86-
position_seconds: float = Field(description="Timeline time in seconds to place the media at")
87-
pixels_per_second: int = Field(description="Pixels per second to convert time to pixels")
100+
position_seconds: float = Field(
101+
description="Timeline time in seconds to place the media at"
102+
)
103+
pixels_per_second: int = Field(
104+
description="Pixels per second to convert time to pixels"
105+
)
88106

89107

90108
class LLMDeleteScrubbersInTrackArgs(BaseSchema):
91109
function_name: Literal["LLMDeleteScrubbersInTrack"] = Field(
92110
description="The name of the function to call"
93111
)
94-
track_number: int = Field(description="1-based track number whose scrubbers will be removed")
112+
track_number: int = Field(
113+
description="1-based track number whose scrubbers will be removed"
114+
)
95115

96116

97117
class FunctionCallResponse(BaseSchema):
98-
function_call: LLMAddScrubberToTimelineArgs | LLMMoveScrubberArgs | LLMAddScrubberByNameArgs | LLMDeleteScrubbersInTrackArgs | None = None
118+
function_call: (
119+
LLMAddScrubberToTimelineArgs
120+
| LLMMoveScrubberArgs
121+
| LLMAddScrubberByNameArgs
122+
| LLMDeleteScrubbersInTrackArgs
123+
| None
124+
) = None
99125
assistant_message: str | None = None

backend/auth/__init__.py

Whitespace-only changes.

backend/auth/routes.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import os
2+
3+
import asyncpg # type: ignore[import-untyped]
4+
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, status
5+
from fastapi.responses import JSONResponse
6+
7+
from auth.schema import AuthResponse, KimuJWT, KimuPayload, SignUpGoogleRequest
8+
from auth.service import (
9+
COOKIE_MAX_AGE,
10+
COOKIE_NAME,
11+
generate_kimu_jwt,
12+
verify_google_id_token,
13+
verify_kimu_jwt,
14+
)
15+
16+
router = APIRouter(prefix="/auth", tags=["auth"])
17+
18+
GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "")
19+
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
20+
DATABASE_URL: str = os.getenv("DATABASE_URL", "")
21+
22+
_pool: asyncpg.Pool | None = None
23+
24+
25+
async def get_db_pool() -> asyncpg.Pool:
26+
"""
27+
Return the shared asyncpg connection pool, creating it on first call.
28+
"""
29+
global _pool
30+
if _pool is None:
31+
_pool = await asyncpg.create_pool(DATABASE_URL)
32+
return _pool
33+
34+
35+
async def get_current_user(
36+
request: Request,
37+
kimu_session: str | None = Cookie(default=None, alias=COOKIE_NAME),
38+
) -> KimuJWT:
39+
"""
40+
FastAPI dependeny. Reads the session JWT from the HttpOnly cookie.
41+
Falls back to the Authorization header if the cookie is absent. Throws an error if the token is invalid.
42+
"""
43+
token = kimu_session
44+
45+
if token is None:
46+
auth_header = request.headers.get("Authorization")
47+
if auth_header and auth_header.startswith("Bearer "):
48+
token = auth_header.removeprefix("Bearer ")
49+
50+
if token is None:
51+
raise HTTPException(
52+
status_code=status.HTTP_401_UNAUTHORIZED,
53+
detail="Not authenticated",
54+
)
55+
56+
try:
57+
return verify_kimu_jwt(token, JWT_SECRET)
58+
except Exception as exc:
59+
raise HTTPException(
60+
status_code=status.HTTP_401_UNAUTHORIZED,
61+
detail=str(exc),
62+
) from exc
63+
64+
65+
@router.post("/google")
66+
async def google_sign_in(body: SignUpGoogleRequest) -> JSONResponse:
67+
"""
68+
Verify the Google ID token, upsert the user, return user info and
69+
set an HttpOnly session cookie with the Kimu JWT.
70+
"""
71+
# 1. Verify the Google credential
72+
try:
73+
google_user = verify_google_id_token(body.credential, GOOGLE_CLIENT_ID)
74+
except ValueError as exc:
75+
raise HTTPException(
76+
status_code=status.HTTP_401_UNAUTHORIZED,
77+
detail=f"Google token verification failed: {exc}",
78+
) from exc
79+
80+
# 2. Upsert user in Postgres
81+
pool = await get_db_pool()
82+
async with pool.acquire() as conn:
83+
row = await conn.fetchrow(
84+
"SELECT id, email, name FROM users WHERE email = $1",
85+
google_user.email,
86+
)
87+
88+
is_new_user = row is None
89+
90+
if is_new_user:
91+
row = await conn.fetchrow(
92+
"""
93+
INSERT INTO users (email, name)
94+
VALUES ($1, $2)
95+
RETURNING id, email, name
96+
""",
97+
google_user.email,
98+
google_user.name,
99+
)
100+
101+
if row is None:
102+
raise HTTPException(
103+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
104+
detail="Failed to create or fetch user",
105+
)
106+
107+
user_id = str(row["id"])
108+
109+
# 3. Generate Kimu JWT
110+
payload = KimuPayload(
111+
user_id=user_id,
112+
email=google_user.email,
113+
name=google_user.name,
114+
avatar_url=google_user.picture,
115+
)
116+
token = generate_kimu_jwt(payload, JWT_SECRET)
117+
118+
# 4. Build response with HttpOnly cookie
119+
body_data = AuthResponse(
120+
user_id=user_id,
121+
email=google_user.email,
122+
name=google_user.name,
123+
avatar_url=google_user.picture,
124+
)
125+
response = JSONResponse(content=body_data.model_dump())
126+
response.set_cookie(
127+
key=COOKIE_NAME,
128+
value=token,
129+
max_age=COOKIE_MAX_AGE,
130+
httponly=True,
131+
secure=True,
132+
samesite="lax",
133+
path="/",
134+
)
135+
return response
136+
137+
138+
@router.get("/me", response_model=KimuPayload)
139+
async def get_me(user: KimuJWT = Depends(get_current_user)) -> KimuPayload:
140+
"""
141+
Return the current user's profile from the JWT.
142+
"""
143+
return KimuPayload(
144+
user_id=user.user_id,
145+
email=user.email,
146+
name=user.name,
147+
avatar_url=user.avatar_url,
148+
)

0 commit comments

Comments
 (0)