woolroom 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/admin.py +396 -0
- app/api/deps.py +78 -0
- app/api/http.py +1221 -0
- app/api/ws.py +232 -0
- app/auth/__init__.py +0 -0
- app/auth/session.py +46 -0
- app/auth/site_access.py +128 -0
- app/channels/__init__.py +0 -0
- app/channels/base.py +17 -0
- app/channels/webapp.py +341 -0
- app/config.py +163 -0
- app/data/__init__.py +0 -0
- app/data/body_language.py +1015 -0
- app/data/quirks_catalog.py +253 -0
- app/data/species.py +83 -0
- app/data/voice.py +302 -0
- app/engine/__init__.py +0 -0
- app/engine/aging.py +120 -0
- app/engine/mood.py +132 -0
- app/engine/outings.py +92 -0
- app/engine/quirks.py +286 -0
- app/eval/__init__.py +16 -0
- app/eval/corpus.py +110 -0
- app/eval/runner.py +255 -0
- app/main.py +511 -0
- app/memory/__init__.py +0 -0
- app/memory/buffer.py +112 -0
- app/memory/core.py +44 -0
- app/memory/moments.py +206 -0
- app/packs/__init__.py +52 -0
- app/packs/lint.py +3 -0
- app/packs/loader.py +237 -0
- app/packs/profiles/dog/pack.yaml +5 -0
- app/packs/profiles/dog/phrases/dog.yaml +536 -0
- app/packs/profiles/dog/species/dog.svg +56 -0
- app/packs/profiles/dog/species/dog.yaml +36 -0
- app/packs/profiles/dog/voice.yaml +5 -0
- app/packs/profiles/pig/pack.yaml +5 -0
- app/packs/profiles/pig/phrases/pig.yaml +536 -0
- app/packs/profiles/pig/species/pig.svg +53 -0
- app/packs/profiles/pig/species/pig.yaml +30 -0
- app/packs/profiles/pig/voice.yaml +4 -0
- app/packs/sanitize.py +39 -0
- app/room_contract.py +92 -0
- app/runtime/__init__.py +0 -0
- app/runtime/actions.py +336 -0
- app/runtime/client.py +165 -0
- app/runtime/llm_log.py +175 -0
- app/runtime/pet_state.py +381 -0
- app/runtime/prompt.py +103 -0
- app/runtime/respond.py +235 -0
- app/runtime/scene_fx.py +237 -0
- app/runtime/shared_trace.py +38 -0
- app/runtime/validator.py +75 -0
- app/runtime/visits.py +78 -0
- app/scheduler/__init__.py +0 -0
- app/scheduler/jobs.py +319 -0
- app/static/access.html +172 -0
- app/static/app.js +70 -0
- app/static/apple-touch-icon.png +0 -0
- app/static/favicon.svg +14 -0
- app/static/icon-192.png +0 -0
- app/static/icon-512-maskable.png +0 -0
- app/static/icon-512.png +0 -0
- app/static/index.html +946 -0
- app/static/js/api.js +632 -0
- app/static/js/figures.js +245 -0
- app/static/js/memory.js +55 -0
- app/static/js/presence.js +188 -0
- app/static/js/quirks.js +195 -0
- app/static/js/sound.js +316 -0
- app/static/js/state.js +117 -0
- app/static/js/ui.js +86 -0
- app/static/js/wool.js +615 -0
- app/static/js/woolevents.js +508 -0
- app/static/js/woolfx.js +329 -0
- app/static/js/woolvisits.js +146 -0
- app/static/js/ws.js +303 -0
- app/static/manifest.json +17 -0
- app/static/style.css +1647 -0
- app/static/vendor/alpine-3.14.1.min.js +5 -0
- app/storage/__init__.py +0 -0
- app/storage/db.py +30 -0
- app/storage/models.py +222 -0
- app/storage/repo.py +707 -0
- app/time.py +46 -0
- woolroom/__init__.py +78 -0
- woolroom/adoption.py +33 -0
- woolroom/auth.py +54 -0
- woolroom/database.py +753 -0
- woolroom/migrations/README +3 -0
- woolroom/migrations/__init__.py +1 -0
- woolroom/migrations/env.py +114 -0
- woolroom/migrations/script.py.mako +28 -0
- woolroom/migrations/versions/48575234f9ca_initial_schema.py +196 -0
- woolroom/migrations/versions/__init__.py +1 -0
- woolroom/overlay.py +114 -0
- woolroom/py.typed +1 -0
- woolroom-0.3.0.dist-info/METADATA +265 -0
- woolroom-0.3.0.dist-info/RECORD +107 -0
- woolroom-0.3.0.dist-info/WHEEL +5 -0
- woolroom-0.3.0.dist-info/entry_points.txt +2 -0
- woolroom-0.3.0.dist-info/licenses/LICENSE +21 -0
- woolroom-0.3.0.dist-info/licenses/packages/woolpack/LICENSE-CC0 +121 -0
- woolroom-0.3.0.dist-info/top_level.txt +2 -0
app/__init__.py
ADDED
|
File without changes
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/admin.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Token-gated /admin/* operator routes: user/pet triage, merges, hard
|
|
2
|
+
deletes, recovery-link management, LLM telemetry. Separate from http.py so
|
|
3
|
+
the ops surface and the product surface read apart; auth is the
|
|
4
|
+
X-Admin-Token header against settings.admin_token (empty disables admin
|
|
5
|
+
entirely), throttled by main.py's auth-failure middleware."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import secrets as _secrets
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlalchemy import select as _sa_select
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
from app.api.deps import db
|
|
17
|
+
from app.api.http import _absolute_url
|
|
18
|
+
from app.config import settings
|
|
19
|
+
from app.storage import repo
|
|
20
|
+
from app.storage.models import User
|
|
21
|
+
from app.time import iso_z, utc_now
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
class RegenRecoveryIn(BaseModel):
|
|
26
|
+
user_id: str | None = None
|
|
27
|
+
display_name: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_admin(token_header: str | None) -> None:
|
|
31
|
+
if not settings.admin_token:
|
|
32
|
+
raise HTTPException(status_code=403, detail="admin disabled")
|
|
33
|
+
if not token_header or not _secrets.compare_digest(token_header, settings.admin_token):
|
|
34
|
+
raise HTTPException(status_code=403, detail="bad admin token")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/admin/users")
|
|
38
|
+
async def admin_list_users(
|
|
39
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
40
|
+
session: AsyncSession = Depends(db),
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""List every user with whether they're a participant, what pet, and when
|
|
43
|
+
they were last seen. For triage when display_name lookup is ambiguous
|
|
44
|
+
(e.g. a test user and a real user sharing one display name)."""
|
|
45
|
+
_require_admin(x_admin_token)
|
|
46
|
+
from app.storage.models import PetParticipant
|
|
47
|
+
q = (
|
|
48
|
+
_sa_select(User, PetParticipant.pet_id)
|
|
49
|
+
.outerjoin(PetParticipant, PetParticipant.user_id == User.id)
|
|
50
|
+
.order_by(User.created_at.asc(), User.id.asc())
|
|
51
|
+
)
|
|
52
|
+
rows = (await session.execute(q)).all()
|
|
53
|
+
results: list[dict] = []
|
|
54
|
+
for user, pet_id in rows:
|
|
55
|
+
pet = await repo.get_pet(session, pet_id) if pet_id else None
|
|
56
|
+
results.append({
|
|
57
|
+
"user_id": user.id,
|
|
58
|
+
"display_name": user.display_name,
|
|
59
|
+
"created_at": iso_z(user.created_at),
|
|
60
|
+
"last_seen_at": iso_z(user.last_seen_at),
|
|
61
|
+
"is_participant": pet is not None,
|
|
62
|
+
"pet_id": pet.id if pet else None,
|
|
63
|
+
"pet_name": pet.name if pet else None,
|
|
64
|
+
})
|
|
65
|
+
return {"users": results}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.delete("/admin/user/{user_id}")
|
|
69
|
+
async def admin_delete_user(
|
|
70
|
+
user_id: str,
|
|
71
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
72
|
+
session: AsyncSession = Depends(db),
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Hard-delete a user. Cascades by hand since the schema doesn't declare
|
|
75
|
+
ON DELETE CASCADE. PetParticipant rows are deleted (orphan participant
|
|
76
|
+
rows would dangle). BufferEvent + Outing user_id is nulled (we keep the
|
|
77
|
+
events, just untie them from the deleted user). MagicLink rows for that
|
|
78
|
+
user are deleted (no FK, plain string match on issued_for)."""
|
|
79
|
+
_require_admin(x_admin_token)
|
|
80
|
+
from sqlalchemy import delete, update
|
|
81
|
+
from app.storage.models import BufferEvent, MagicLink, Outing, PetParticipant
|
|
82
|
+
user = await session.get(User, user_id)
|
|
83
|
+
if not user:
|
|
84
|
+
raise HTTPException(status_code=404, detail="no such user")
|
|
85
|
+
await session.execute(delete(PetParticipant).where(PetParticipant.user_id == user_id))
|
|
86
|
+
await session.execute(update(BufferEvent).where(BufferEvent.user_id == user_id).values(user_id=None))
|
|
87
|
+
await session.execute(update(Outing).where(Outing.triggered_by_user_id == user_id).values(triggered_by_user_id=None))
|
|
88
|
+
await session.execute(delete(MagicLink).where(MagicLink.issued_for == user_id))
|
|
89
|
+
await session.delete(user)
|
|
90
|
+
await session.commit()
|
|
91
|
+
return {"deleted_user_id": user_id, "display_name": user.display_name}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MergePetIn(BaseModel):
|
|
95
|
+
source_pet_id: str
|
|
96
|
+
target_pet_id: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.post("/admin/merge-pet")
|
|
100
|
+
async def admin_merge_pet(
|
|
101
|
+
body: MergePetIn,
|
|
102
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
103
|
+
session: AsyncSession = Depends(db),
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Reparent everything from source_pet_id onto target_pet_id, then delete
|
|
106
|
+
the source pet. Useful for "someone accidentally adopted a duplicate pet —
|
|
107
|
+
fold it into the shared one." The (pet_id, user_id) PK is honored: if
|
|
108
|
+
source's participant is already in target, source's row is dropped
|
|
109
|
+
instead of moved."""
|
|
110
|
+
_require_admin(x_admin_token)
|
|
111
|
+
from sqlalchemy import delete, update
|
|
112
|
+
from app.storage.models import (
|
|
113
|
+
BufferEvent, CoreFact, MagicLink, Moment, Outing, Pet, PetParticipant,
|
|
114
|
+
)
|
|
115
|
+
if body.source_pet_id == body.target_pet_id:
|
|
116
|
+
raise HTTPException(status_code=400, detail="source and target are the same")
|
|
117
|
+
source = await session.get(Pet, body.source_pet_id)
|
|
118
|
+
target = await session.get(Pet, body.target_pet_id)
|
|
119
|
+
if not source or not target:
|
|
120
|
+
raise HTTPException(status_code=404, detail="source or target pet not found")
|
|
121
|
+
|
|
122
|
+
moved_events = 0
|
|
123
|
+
moved_moments = 0
|
|
124
|
+
moved_participants = 0
|
|
125
|
+
dropped_participants = 0
|
|
126
|
+
|
|
127
|
+
# Participants: per-user unique constraint means a user can be in only one
|
|
128
|
+
# pet at a time. Use raw UPDATE (not ORM delete+insert) so the unique index
|
|
129
|
+
# never sees both rows simultaneously.
|
|
130
|
+
target_user_ids_subq = (
|
|
131
|
+
_sa_select(PetParticipant.user_id)
|
|
132
|
+
.where(PetParticipant.pet_id == body.target_pet_id)
|
|
133
|
+
.scalar_subquery()
|
|
134
|
+
)
|
|
135
|
+
r = await session.execute(
|
|
136
|
+
update(PetParticipant)
|
|
137
|
+
.where(
|
|
138
|
+
PetParticipant.pet_id == body.source_pet_id,
|
|
139
|
+
PetParticipant.user_id.notin_(target_user_ids_subq),
|
|
140
|
+
)
|
|
141
|
+
.values(pet_id=body.target_pet_id)
|
|
142
|
+
)
|
|
143
|
+
moved_participants = int(r.rowcount or 0)
|
|
144
|
+
r = await session.execute(
|
|
145
|
+
delete(PetParticipant).where(PetParticipant.pet_id == body.source_pet_id)
|
|
146
|
+
)
|
|
147
|
+
dropped_participants = int(r.rowcount or 0)
|
|
148
|
+
|
|
149
|
+
# Events + moments: reparent to target so the activity history follows.
|
|
150
|
+
r = await session.execute(
|
|
151
|
+
update(BufferEvent)
|
|
152
|
+
.where(BufferEvent.pet_id == body.source_pet_id)
|
|
153
|
+
.values(pet_id=body.target_pet_id)
|
|
154
|
+
)
|
|
155
|
+
moved_events = int(r.rowcount or 0)
|
|
156
|
+
r = await session.execute(
|
|
157
|
+
update(Moment)
|
|
158
|
+
.where(Moment.pet_id == body.source_pet_id)
|
|
159
|
+
.values(pet_id=body.target_pet_id)
|
|
160
|
+
)
|
|
161
|
+
moved_moments = int(r.rowcount or 0)
|
|
162
|
+
|
|
163
|
+
# Core facts: key conflicts likely — drop source's facts to avoid overwriting
|
|
164
|
+
# target's lovingly-curated adopted_by, etc.
|
|
165
|
+
await session.execute(delete(CoreFact).where(CoreFact.pet_id == body.source_pet_id))
|
|
166
|
+
# Outings + magic_links scoped to source: drop. Stale once source is gone.
|
|
167
|
+
await session.execute(delete(Outing).where(Outing.pet_id == body.source_pet_id))
|
|
168
|
+
await session.execute(delete(MagicLink).where(MagicLink.pet_id == body.source_pet_id))
|
|
169
|
+
|
|
170
|
+
source_name = source.name
|
|
171
|
+
await session.delete(source)
|
|
172
|
+
await session.commit()
|
|
173
|
+
return {
|
|
174
|
+
"source_pet_id": body.source_pet_id,
|
|
175
|
+
"source_name": source_name,
|
|
176
|
+
"target_pet_id": body.target_pet_id,
|
|
177
|
+
"target_name": target.name,
|
|
178
|
+
"moved_participants": moved_participants,
|
|
179
|
+
"dropped_participants": dropped_participants,
|
|
180
|
+
"moved_events": moved_events,
|
|
181
|
+
"moved_moments": moved_moments,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.delete("/admin/pet/{pet_id}")
|
|
186
|
+
async def admin_delete_pet(
|
|
187
|
+
pet_id: str,
|
|
188
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
189
|
+
session: AsyncSession = Depends(db),
|
|
190
|
+
) -> dict:
|
|
191
|
+
"""Hard-delete a pet. Cascades through every per-pet row (participants,
|
|
192
|
+
events, moments, core_facts, outings, pet-scoped magic_links). The Pet
|
|
193
|
+
itself goes last so foreign-key constraints don't fire mid-transaction."""
|
|
194
|
+
_require_admin(x_admin_token)
|
|
195
|
+
from sqlalchemy import delete
|
|
196
|
+
from app.storage.models import (
|
|
197
|
+
BufferEvent, CoreFact, MagicLink, Moment, Outing, Pet, PetParticipant,
|
|
198
|
+
)
|
|
199
|
+
pet = await session.get(Pet, pet_id)
|
|
200
|
+
if not pet:
|
|
201
|
+
raise HTTPException(status_code=404, detail="no such pet")
|
|
202
|
+
await session.execute(delete(PetParticipant).where(PetParticipant.pet_id == pet_id))
|
|
203
|
+
await session.execute(delete(BufferEvent).where(BufferEvent.pet_id == pet_id))
|
|
204
|
+
await session.execute(delete(Moment).where(Moment.pet_id == pet_id))
|
|
205
|
+
await session.execute(delete(CoreFact).where(CoreFact.pet_id == pet_id))
|
|
206
|
+
await session.execute(delete(Outing).where(Outing.pet_id == pet_id))
|
|
207
|
+
await session.execute(delete(MagicLink).where(MagicLink.pet_id == pet_id))
|
|
208
|
+
pet_name = pet.name
|
|
209
|
+
await session.delete(pet)
|
|
210
|
+
await session.commit()
|
|
211
|
+
return {"deleted_pet_id": pet_id, "name": pet_name}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.post("/admin/regenerate-recovery")
|
|
215
|
+
async def admin_regenerate_recovery(
|
|
216
|
+
request: Request,
|
|
217
|
+
body: RegenRecoveryIn,
|
|
218
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
219
|
+
session: AsyncSession = Depends(db),
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""Mint a fresh recovery URL for one or more users. Use when a user has lost
|
|
222
|
+
their cookie AND their saved recovery URL — the only way back to their
|
|
223
|
+
original account otherwise is direct DB intervention.
|
|
224
|
+
|
|
225
|
+
Auth: X-Admin-Token header must match settings.admin_token.
|
|
226
|
+
Match: either user_id (single) or display_name (may match multiple).
|
|
227
|
+
"""
|
|
228
|
+
_require_admin(x_admin_token)
|
|
229
|
+
if body.user_id:
|
|
230
|
+
existing = await session.get(User, body.user_id)
|
|
231
|
+
users = [existing] if existing else []
|
|
232
|
+
elif body.display_name:
|
|
233
|
+
q = _sa_select(User).where(User.display_name == body.display_name)
|
|
234
|
+
users = list((await session.execute(q)).scalars().all())
|
|
235
|
+
else:
|
|
236
|
+
raise HTTPException(status_code=400, detail="user_id or display_name required")
|
|
237
|
+
if not users:
|
|
238
|
+
raise HTTPException(status_code=404, detail="no matching user")
|
|
239
|
+
|
|
240
|
+
results: list[dict] = []
|
|
241
|
+
for u in users:
|
|
242
|
+
# Additive — older URLs for this user stay valid. Use admin_revoke
|
|
243
|
+
# for the rare "I think this leaked" case.
|
|
244
|
+
link = await repo.mint_recovery_link(session, u.id)
|
|
245
|
+
pet = await repo.get_pet_for_user(session, u.id)
|
|
246
|
+
results.append({
|
|
247
|
+
"user_id": u.id,
|
|
248
|
+
"display_name": u.display_name,
|
|
249
|
+
"pet_id": pet.id if pet else None,
|
|
250
|
+
"pet_name": pet.name if pet else None,
|
|
251
|
+
"is_participant": pet is not None,
|
|
252
|
+
"recovery_url": _absolute_url(request, f"/r/{link.token}"),
|
|
253
|
+
})
|
|
254
|
+
await session.commit()
|
|
255
|
+
return {"results": results}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.post("/admin/revoke-recovery")
|
|
259
|
+
async def admin_revoke_recovery(
|
|
260
|
+
body: RegenRecoveryIn,
|
|
261
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
262
|
+
session: AsyncSession = Depends(db),
|
|
263
|
+
) -> dict:
|
|
264
|
+
"""Nuke every recovery token for a user. Use after a suspected leak.
|
|
265
|
+
Pair with regenerate-recovery to issue a fresh URL afterward."""
|
|
266
|
+
_require_admin(x_admin_token)
|
|
267
|
+
if body.user_id:
|
|
268
|
+
target_ids = [body.user_id]
|
|
269
|
+
elif body.display_name:
|
|
270
|
+
q = _sa_select(User.id).where(User.display_name == body.display_name)
|
|
271
|
+
target_ids = list((await session.execute(q)).scalars().all())
|
|
272
|
+
else:
|
|
273
|
+
raise HTTPException(status_code=400, detail="user_id or display_name required")
|
|
274
|
+
if not target_ids:
|
|
275
|
+
raise HTTPException(status_code=404, detail="no matching user")
|
|
276
|
+
from sqlalchemy import delete
|
|
277
|
+
from app.storage.models import MagicLink as _ML
|
|
278
|
+
total = 0
|
|
279
|
+
for uid in target_ids:
|
|
280
|
+
result = await session.execute(
|
|
281
|
+
delete(_ML).where(_ML.issued_for == uid, _ML.purpose == "recovery")
|
|
282
|
+
)
|
|
283
|
+
total += int(result.rowcount or 0)
|
|
284
|
+
await session.commit()
|
|
285
|
+
return {"revoked_count": total, "user_ids": target_ids}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.get("/admin/llm/stats")
|
|
289
|
+
async def admin_llm_stats(
|
|
290
|
+
x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
|
|
291
|
+
hours: int = 24,
|
|
292
|
+
session: AsyncSession = Depends(db),
|
|
293
|
+
) -> dict:
|
|
294
|
+
"""Aggregate LLM-call telemetry over the last N hours (default 24).
|
|
295
|
+
|
|
296
|
+
Returns total calls, status distribution, validator acceptance rate,
|
|
297
|
+
latency percentiles, and per-(provider, model, prompt_version) breakdown.
|
|
298
|
+
Backs the "we measure our LLM stack" claim on the portfolio page.
|
|
299
|
+
"""
|
|
300
|
+
_require_admin(x_admin_token)
|
|
301
|
+
from datetime import timedelta
|
|
302
|
+
from sqlalchemy import func as _f
|
|
303
|
+
from app.storage.models import LLMCall
|
|
304
|
+
|
|
305
|
+
cutoff = utc_now() - timedelta(hours=max(1, hours))
|
|
306
|
+
|
|
307
|
+
total = (
|
|
308
|
+
await session.execute(
|
|
309
|
+
_sa_select(_f.count(LLMCall.id)).where(LLMCall.ts >= cutoff)
|
|
310
|
+
)
|
|
311
|
+
).scalar_one()
|
|
312
|
+
|
|
313
|
+
status_rows = (
|
|
314
|
+
await session.execute(
|
|
315
|
+
_sa_select(LLMCall.status, _f.count(LLMCall.id))
|
|
316
|
+
.where(LLMCall.ts >= cutoff)
|
|
317
|
+
.group_by(LLMCall.status)
|
|
318
|
+
)
|
|
319
|
+
).all()
|
|
320
|
+
|
|
321
|
+
verdict_rows = (
|
|
322
|
+
await session.execute(
|
|
323
|
+
_sa_select(LLMCall.validator_verdict, _f.count(LLMCall.id))
|
|
324
|
+
.where(LLMCall.ts >= cutoff, LLMCall.status == "ok")
|
|
325
|
+
.group_by(LLMCall.validator_verdict)
|
|
326
|
+
)
|
|
327
|
+
).all()
|
|
328
|
+
|
|
329
|
+
# Per-(provider, model, prompt_version) latency + status snapshot.
|
|
330
|
+
breakdown_rows = (
|
|
331
|
+
await session.execute(
|
|
332
|
+
_sa_select(
|
|
333
|
+
LLMCall.provider,
|
|
334
|
+
LLMCall.model,
|
|
335
|
+
LLMCall.prompt_version,
|
|
336
|
+
_f.count(LLMCall.id),
|
|
337
|
+
_f.avg(LLMCall.latency_ms),
|
|
338
|
+
_f.max(LLMCall.latency_ms),
|
|
339
|
+
_f.min(LLMCall.latency_ms),
|
|
340
|
+
)
|
|
341
|
+
.where(LLMCall.ts >= cutoff)
|
|
342
|
+
.group_by(LLMCall.provider, LLMCall.model, LLMCall.prompt_version)
|
|
343
|
+
)
|
|
344
|
+
).all()
|
|
345
|
+
|
|
346
|
+
ok_latencies = (
|
|
347
|
+
await session.execute(
|
|
348
|
+
_sa_select(LLMCall.latency_ms)
|
|
349
|
+
.where(LLMCall.ts >= cutoff, LLMCall.status == "ok")
|
|
350
|
+
.order_by(LLMCall.latency_ms.asc())
|
|
351
|
+
)
|
|
352
|
+
).scalars().all()
|
|
353
|
+
|
|
354
|
+
def _pct(values: list[int], q: float) -> int | None:
|
|
355
|
+
if not values:
|
|
356
|
+
return None
|
|
357
|
+
i = min(len(values) - 1, int(len(values) * q))
|
|
358
|
+
return int(values[i])
|
|
359
|
+
|
|
360
|
+
accepted = next((c for v, c in verdict_rows if v == "accepted"), 0)
|
|
361
|
+
rejected = next((c for v, c in verdict_rows if v == "rejected"), 0)
|
|
362
|
+
pending_verdict = next((c for v, c in verdict_rows if v == "n/a"), 0)
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"window_hours": hours,
|
|
366
|
+
"total_calls": int(total),
|
|
367
|
+
"status_counts": {s: int(c) for s, c in status_rows},
|
|
368
|
+
"validator": {
|
|
369
|
+
"accepted": int(accepted),
|
|
370
|
+
"rejected": int(rejected),
|
|
371
|
+
"pending_or_na": int(pending_verdict),
|
|
372
|
+
"acceptance_rate": (
|
|
373
|
+
round(accepted / (accepted + rejected), 4)
|
|
374
|
+
if (accepted + rejected) > 0
|
|
375
|
+
else None
|
|
376
|
+
),
|
|
377
|
+
},
|
|
378
|
+
"latency_ms": {
|
|
379
|
+
"p50": _pct(list(ok_latencies), 0.50),
|
|
380
|
+
"p95": _pct(list(ok_latencies), 0.95),
|
|
381
|
+
"max": int(max(ok_latencies)) if ok_latencies else None,
|
|
382
|
+
"n": len(ok_latencies),
|
|
383
|
+
},
|
|
384
|
+
"by_prompt_version": [
|
|
385
|
+
{
|
|
386
|
+
"provider": p,
|
|
387
|
+
"model": m,
|
|
388
|
+
"prompt_version": v,
|
|
389
|
+
"count": int(c),
|
|
390
|
+
"avg_latency_ms": int(avg) if avg is not None else None,
|
|
391
|
+
"min_latency_ms": int(lo) if lo is not None else None,
|
|
392
|
+
"max_latency_ms": int(hi) if hi is not None else None,
|
|
393
|
+
}
|
|
394
|
+
for p, m, v, c, avg, hi, lo in breakdown_rows
|
|
395
|
+
],
|
|
396
|
+
}
|
app/api/deps.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""FastAPI dependencies. Session-cookie user + pet lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.auth.session import load_user
|
|
11
|
+
from app.storage import repo
|
|
12
|
+
from app.storage.db import SessionLocal
|
|
13
|
+
from app.storage.models import Pet, User
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def db() -> AsyncIterator[AsyncSession]:
|
|
17
|
+
async with SessionLocal() as session:
|
|
18
|
+
try:
|
|
19
|
+
yield session
|
|
20
|
+
await session.commit()
|
|
21
|
+
except Exception:
|
|
22
|
+
await session.rollback()
|
|
23
|
+
raise
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def current_user_optional(
|
|
27
|
+
request: Request,
|
|
28
|
+
session: AsyncSession = Depends(db),
|
|
29
|
+
) -> User | None:
|
|
30
|
+
namespace = request.app.state.auth_namespace
|
|
31
|
+
return await load_user(
|
|
32
|
+
session,
|
|
33
|
+
request.cookies.get(namespace.session_cookie),
|
|
34
|
+
namespace,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def current_user(
|
|
39
|
+
user: User | None = Depends(current_user_optional),
|
|
40
|
+
) -> User:
|
|
41
|
+
if user is None:
|
|
42
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session")
|
|
43
|
+
return user
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def current_pet(
|
|
47
|
+
request: Request,
|
|
48
|
+
user: User = Depends(current_user),
|
|
49
|
+
session: AsyncSession = Depends(db),
|
|
50
|
+
) -> Pet:
|
|
51
|
+
"""The room the caller is acting in. Rooms are addressed explicitly via
|
|
52
|
+
the ?pet=<id> query param (every room-scoped route, GET or POST); without
|
|
53
|
+
it, fall back to the human's active room (last-left, else founding).
|
|
54
|
+
|
|
55
|
+
An unconfirmed co-adoption participant gets a 403 everywhere except the
|
|
56
|
+
ceremony endpoints — the second cat's room opens for them once they've
|
|
57
|
+
picked its second quirk."""
|
|
58
|
+
requested = request.query_params.get("pet")
|
|
59
|
+
if requested:
|
|
60
|
+
participant = await repo.get_participant(session, requested, user.id)
|
|
61
|
+
if participant is None:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
64
|
+
detail="not your room",
|
|
65
|
+
)
|
|
66
|
+
if participant.confirmed_adoption_at is None:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
69
|
+
detail="meet him first — pick his second habit",
|
|
70
|
+
)
|
|
71
|
+
pet = await repo.get_pet(session, requested)
|
|
72
|
+
if pet is None:
|
|
73
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no such room")
|
|
74
|
+
return pet
|
|
75
|
+
pet = await repo.resolve_active_pet(session, user)
|
|
76
|
+
if pet is None:
|
|
77
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no pet yet")
|
|
78
|
+
return pet
|