Architektura¶
Ten projekt trzyma się architektury warstwowej Repository + Service. Każda funkcja — użytkownicy, rozmowy, pliki, dokumenty RAG, źródła synchronizacji — korzysta z tego samego wzorca: Models → Schemas → Repositories → Services → Endpoints.
Przepływ żądania¶
flowchart LR
Q([HTTP request]) --> R[API route]
R --> S[Service]
S --> P[Repository]
P --> D[(PostgreSQL)]
D -.-> P
P -.-> S
S -.-> R
R -.-> A([Response])
Route'y nigdy nie zawierają bezpośrednich wywołań bazy danych. Cały dostęp do danych idzie przez serwisy, które z kolei delegują do repozytoriów.
To test, a nie konwencja
backend/tests/test_route_layering.py nie przechodzi, jeśli route importuje
repozytorium — i nie przechodzi tak samo głośno, jeśli jego lista dozwolonych
trzyma wyjątek, który już nie obowiązuje.
Zasada rozjechała się w pięciu modułach, zanim cokolwiek zaczęło jej pilnować —
w żadnym nie był to wyciek, bo każdy handler przekazywał ten zakres, który
akurat znał. I to jest właśnie koszt: zakres, którego właścicielem jest route, to zakres,
którego nie widzi żaden test serwisu, a następny czytelnik encji musi wiedzieć, że ma
przekazać to samo. Jedynym wyjątkiem jest Literal z porządkami sortowania,
importowany jako typ, a nie jako dostęp do danych.
Struktura katalogów (backend/app/)¶
| Katalog / plik | Do czego służy |
|---|---|
api/routes/v1/ |
Endpointy HTTP, walidacja żądań, uwierzytelnianie |
api/deps.py |
Wstrzykiwanie zależności (sesja bazy, bieżący użytkownik) |
services/ |
Logika biznesowa, orkiestracja |
↳ user.py |
CRUD użytkownika, aktualizacje profilu |
↳ conversation.py |
Zarządzanie rozmowami i wiadomościami |
↳ message_rating.py |
CRUD ocen wiadomości, statystyki, eksport |
↳ file_upload.py |
Obsługa wgrywania plików w czacie |
↳ file_storage.py |
Abstrakcja przechowywania plików (lokalnie / S3) |
↳ rag_document.py |
Cykl życia dokumentu RAG |
↳ rag_sync.py |
Orkiestracja synchronizacji ze zdalnym źródłem |
↳ sync_source.py |
CRUD źródeł synchronizacji i historia przebiegów jednego źródła |
↳ audit.py |
Odczyt śladu audytowego własnej organizacji wywołującego |
repositories/ |
Warstwa dostępu do danych, zapytania do bazy |
↳ user.py |
Zapytania o użytkowników |
↳ conversation.py |
Zapytania o rozmowy |
↳ chat_file.py |
Zapytania o pliki czatu |
↳ message_rating.py |
Zapytania o oceny wiadomości |
↳ rag_document.py |
Zapytania o dokumenty RAG |
↳ sync_log.py |
Zapytania o logi synchronizacji |
↳ sync_source.py |
Zapytania o źródła synchronizacji |
schemas/ |
Modele żądań/odpowiedzi Pydantic |
↳ user.py |
Schematy użytkownika |
↳ conversation.py |
Schematy rozmów i wiadomości |
↳ file.py |
Schematy wgrywania plików |
↳ message_rating.py |
Schematy ocen wiadomości |
↳ rag.py |
Schematy zapytań/odpowiedzi RAG |
↳ sync_source.py |
Schematy źródeł synchronizacji |
db/models/ |
Modele SQLAlchemy 2.1 |
↳ user.py |
Model użytkownika |
↳ conversation.py |
Modele rozmów i wiadomości |
↳ chat_file.py |
Model pliku czatu |
↳ message_rating.py |
Model oceny wiadomości |
↳ webhook.py |
Model webhooka |
↳ rag_document.py |
Model dokumentu RAG |
↳ sync_log.py |
Model logu synchronizacji |
↳ sync_source.py |
Model źródła synchronizacji |
core/config.py |
Ustawienia przez pydantic-settings |
core/security.py |
Narzędzia do JWT / kluczy API |
agents/ |
Agenci AI i narzędzia |
rag/ |
Moduł RAG (embeddingi, magazyn wektorów, retrieval) |
rag/connectors/ |
Konektory synchronizacji (Google Drive, S3, strony internetowe) |
commands/ |
Komendy CLI w stylu Django |
Odpowiedzialności warstw¶
Route'y API (api/routes/v1/)¶
- Obsługa żądania i odpowiedzi HTTP
- Walidacja wejścia przez schematy Pydantic
- Sprawdzenia uwierzytelnienia i autoryzacji
- Nigdy nie zawierają bezpośrednich wywołań bazy — zawsze delegują do serwisu
- Nigdy nie parsują niezaufanego wejścia w wyrażeniu route'a.
ValidationErrorpodniesiony tam jestValueError, ale nieRequestValidationError, więc żaden handler go nie mapuje i wywołujący dostaje 500 zdetails: null— i tak właśnie każdy błąd w ręcznie edytowanym YAML-u speca był raportowany jako awaria (#873). Parsowanie należy do serwisu, który jest właścicielem, i tak samo odmowa:import_specnaAgentRegistryServiceodpowiada na zepsuty dokument błędem 400, który nazywa pole, i nigdy nie cytuje wywołującemu tego, co ten przysłał.
Serwisy (services/)¶
- Logika biznesowa i walidacja
- Orkiestrują jedno lub więcej wywołań repozytorium
- Podnoszą wyjątki domenowe (
NotFoundError,AlreadyExistsErroritd.) - Zarządzają granicami transakcji
Repozytoria (repositories/)¶
- Wyłącznie operacje na bazie danych
- Żadnej logiki biznesowej
- Używają
db.flush(), a niecommit()— transakcja należy do sesji żądania, która commituje ją przed wysłaniem odpowiedzi - Zwracają modele domenowe
Schematy (schemas/)¶
- Osobne modele
Create,UpdateiResponsena encję - Schematy
Responseużywająmodel_config = ConfigDict(from_attributes=True)do konwersji z ORM
Modele (db/models/)¶
- Definicje modeli SQLAlchemy 2.1
- Tu mieszkają relacje, indeksy i domyślne wartości kolumn
Konektory RAG (rag/connectors/)¶
- Wymienne adaptery synchronizacji implementujące
BaseSyncConnector - Każdy konektor dostarcza
list_files()idownload_file() - Rejestrowane w
CONNECTOR_REGISTRY, żeby dało się je znaleźć w czasie działania
Transakcja żądania¶
Jedno żądanie, jedna sesja, jedna transakcja, commitowana w jednym miejscu — a to miejsce liczy się tak samo jak sam fakt.
Route prosi o DBSession (app/api/deps.py), co rozwiązuje się do
get_db_session (app/db/session.py). Wszystko poniżej route'a dzieli tę jedną
sesję: serwisy przyjmują ją w konstruktorze, repozytoria jako pierwszy argument,
i ani jedne, ani drugie nigdy nie wołają commit() — z jednym celowym wyjątkiem,
ścieżką runu agenta i rozmową głosową, która też jest runem, opisanymi
niżej. flush()
wysyła instrukcje, żeby wiersz miał id, a ograniczenia zostały sprawdzone; commit
następuje raz, po drodze na zewnątrz.
Po drodze na zewnątrz znaczy: zanim odpowiedź zostanie zapisana. Alias
deklaruje Depends(get_db_session, scope="function"), co rejestruje kod wyjścia
sesji na stosie wyjścia, który FastAPI rozwija między powrotem z operacji ścieżki
a await response(scope, receive, send). Kolejność dla żądania jest więc taka:
- route zwraca sterowanie, a
response_modelserializuje to, co zwrócił; - transakcja się commituje — albo, jeśli cokolwiek podniosło wyjątek, wycofuje;
- startuje praca w tle odroczona przez żądanie (niżej);
- odpowiedź zostaje zapisana do gniazda;
- sesja zostaje zamknięta.
sequenceDiagram
autonumber
participant C as Client
participant R as Route
participant S as Session
C->>R: request
R->>S: flush (ids, constraints)
R-->>R: return, response_model serializes
R->>S: COMMIT
S-->>R: committed
R->>R: start deferred background work
R-->>C: 2xx written
R->>S: close
2xx znaczy, że zapis jest do odczytania, a nie tylko przyjęty
Ta kolejność to cały kontrakt. Gołe Depends(get_db_session) gdziekolwiek
przywraca domyślne zachowanie FastAPI i zamienia kroki 2 i 4 miejscami —
tests/api/test_db_session_scope.py nie przechodzi, gdy takie znajdzie.
Ta kolejność jest tym, co pozwala klientowi działać na podstawie własnej
odpowiedzi. Domyślnym zakresem FastAPI dla zależności z yield jest
scope="request", który ustawia kroki 2 i 4 na odwrót — i tak było tutaj do
#353, gdzie przyjęcie zaproszenia odpowiadało 204, podczas gdy utworzony
przez nie wiersz członkostwa pozostawał niewidoczny dla kolejnego żądania przez
21,7 ms, a token zaproszenia był zużywany 34 ms przed commitem transakcji, która
go wybiła.
Trzy konsekwencje, które warto znać, zanim napiszesz route:
- Commit, który się nie powiedzie, to 500, a nie wiersz w logu. Odpowiedź nie została jeszcze zapisana, więc odroczone ograniczenie albo utracone połączenie dociera do klienta jako błąd, zamiast być odkryte za wysłanym już 2xx. Krok 3 też się nie wykonuje: praca czekająca na transakcję, której nie było, zostaje porzucona, z ostrzeżeniem, które ją nazywa.
- Cokolwiek połyka błąd bazy danych, musi zresetować sesję. Instrukcja, która
podniosła wyjątek, zostawia swoją transakcję w stanie przerwanym, a commit
w kroku 2 też podnosi wyjątek. Sondy zdrowia (
app/services/health.py) to ten przypadek w kodzie: celowo nie propagują błędu, więc wycofują transakcję przed zwróceniem wyniku. - Ciało odpowiedzi wytwarzane w trakcie jej wysyłania potrzebuje innej sesji.
StreamingResponsenad generatorem jest iterowana w kroku 3, a wtedy sesja jest już zamknięta. Te endpointy biorąStreamingDBSession, która zachowuje domyślny zakres FastAPI i dlatego jest tylko do odczytu: jej transakcja rozstrzyga się po tym, jak klient dostał odpowiedź. Używa jej dokładnie jeden endpoint — eksport ocen do CSV — atests/api/test_db_session_scope.pyodrzuca drugi, dopóki nie zapadnie co do niego decyzja.
Praca, która przeżywa żądanie, w ogóle nie używa tej sesji. Handlery WebSocketów
i komendy CLI otwierają get_db_context(), a zadania workera
get_worker_db_context(); wszystkie trzy idą przez to samo _managed_session,
więc commitują przy czystym wyjściu z własnego async with i tam samo startują
swoją odroczoną pracę — co nie ma nic wspólnego z odpowiedzią.
Dwa commity ścieżki runu¶
Jedna ścieżka celowo commituje wcześniej niż „po drodze na zewnątrz”: run agenta.
Runner commituje raz przed wywołaniem modelu i jeszcze raz w końcowym
finally — AgentRunnerService._run oraz ChatAgentRunner.run dla
strumieniowanego czatu.
Wywołanie modelu trwa od sekund do minut, a transakcja zostawiona na ten czas
otwarta trzyma połączenie z puli w stanie idle in transaction przez cały ten
okres. Piętnaście równoległych runów potrafiło zająć całą pulę (#12).
Wcześniejszy commit kupuje jeszcze dwie rzeczy: wiersz runa jest do odczytania z każdej innej sesji przez całe życie runa, a wyjście wznowionego runa z kolejki zatwierdzeń jest trwałe, zanim zatwierdzone wywołanie zostanie odtworzone — więc awaria w trakcie odtwarzania nie może wydać tego samego zatwierdzenia dwa razy (#3).
Końcowy commit to druga połowa. Kontekst sesji commituje tylko przy czystym wyjściu, a run zakończony błędem, zatrzymany przez budżet albo anulowany czystym wyjściem nie jest — a run, którego brakuje w historii, to run, za który nikt nie odpowiada.
Obie granice są dowodzone na prawdziwej bazie danych w
tests/integration/test_run_commit_boundary.py.
Rozmowa głosowa jest runem i zachowuje te same dwa commity (#54). Rozmowa trzyma swój socket do pięćdziesięciu minut, więc commituje tam, gdzie ścieżka runu, a wszystko pomiędzy zapisuje przez własne sesje:
VoiceCallStarter.startcommituje raz, gdy wszystkie odmowy zostały już sprawdzone, z zapisanym wierszemvoice_callsi jego runem, zanim provider zostanie wywołany. Wiersze zatwierdzeń, heartbeat i sprzątanie rozmów głosowych potrzebują tych wierszy czytelnych z innych sesji; odmowa przed tym punktem wycofuje wszystko, więc odrzucona rozmowa nie zostawia niczego w Activity.- Każda zapisana tura, każdy 15-sekundowy heartbeat i każdy zapis zatwierdzenia
otwiera własną sesję
get_db_context()i tam commituje, nigdy na wspólnej sesji runu — tura jest trwała w chwili, gdy zostanie wypowiedziana, a awaria traci co najwyżej turę w toku. - Sprzątanie
VoiceCallSession, osłoniętefinally, zapisuje koniec w wierszu rozmowy, potem rozłącza wywołanie providera, kończy run i commituje ponownie — końcowy commit, na każdej drodze wyjścia. Koniec idzie pierwszy, żeby ponowne połączenie, które na niego czeka, nie czekało też na rozłączenie.
tests/test_voice_starter.py::test_a_started_call_is_committed_with_its_row_and_a_voice_run
i tests/test_voice_session.py::test_every_exit_path_hangs_up_the_provider_call
pilnują obu końców.
Jedyny inny wczesny commit¶
SessionService.detect_refresh_reuse jest drugim, i to z odwrotnego powodu: nie
dlatego, że transakcja byłaby trzymana zbyt długo, ale dlatego, że zaraz zostanie
wyrzucona. Refresh token, który nie pasował do żadnej żywej sesji, może być
odtworzeniem tego, który sesja właśnie zrotowała — odpowiedzią jest zakończenie
tego łańcucha i zapisanie tego, a potem odmowa wywołującemu, która podnosi
AuthenticationError przez gałąź wyjątku kontekstu sesji i wycofuje żądanie.
Bez commitu jest to 401, wciąż żywy skompromitowany łańcuch i żaden ślad, że
cokolwiek się wydarzyło. test_the_response_survives_the_refusal_that_follows_it
wycofuje transakcję po wywołaniu i sprawdza, co zostało
(#1519).
Widoczność tnie w obie strony. Cokolwiek rozumowało wcześniej „wiersza wykonującego się runa nie da się zobaczyć”, rozumuje teraz o wierszu, który jest widziany, a scheduler triggerów agenta to jedyne miejsce, które tak rozumowało.
Jego strażnik braku nakładania blokuje na każdym nieterminalnym runie w rozmowie
triggera — co teraz obejmuje też żywy run równoległego run_now albo odpalenia
zdarzeniem, czyli ochronę, której dawna niewidoczność nie mogła dać.
Tymczasem worker, który umiera w trakcie runu, zostawia wiersz running, którego
nic w procesie już nie dokończy. Proces wykonujący run oznacza go jako żywy co
RUN_HEARTBEAT_SECONDS, a wiersz, który przestał dawać znak życia, ogranicza
zamiatanie przeterminowanych runów co minutę, które kończy go jako failed po
przekroczeniu STALE_RUN_REAPED_AFTER_SECONDS. Własnym sygnałem życia zaplanowanego odpalenia
pozostaje odnawiana dzierżawa (app/repositories/agent_trigger.py::claim_due).
Governance mówi, co to zamiatanie rozstrzyga, a czego celowo nie rusza.
Zlecanie pracy w tle z żądania¶
Praca, która musi się wydarzyć, jest zadaniem kolejkowanym we własnej
transakcji żądania. app.services.background_jobs.enqueue zapisuje wiersz
zadania w sesji żądania, więc zostaje zatwierdzony razem z wierszami, które
wskazuje, albo wcale:
from app.services.background_jobs import enqueue
from app.worker.jobs import FIRE_TRIGGER, FireTrigger
await enqueue(self.db, FIRE_TRIGGER, FireTrigger(trigger_id=trigger.id), organization_id=org_id)
Wycofane żądanie niczego nie kolejkuje, a zatwierdzone budzi workery puli przez
NOTIFY, które PostgreSQL dostarcza dopiero przy commicie - więc żaden worker
nie pobierze zadania, zanim istnieje wiersz, który ono czyta. To zamyka
#417 przez samą konstrukcję: wgranie odpowiadające
{"status": "processing"}, które już takie zostawało, bo praca ruszała przed
commitem i jej własna sesja nie widziała dokumentu. Wgranie, synchronizacja
uruchomiona przez kogoś, każde odpalenie triggera - z harmonogramu, z
dostarczenia albo „run now” - asynchroniczny run przez API i sprzątanie tego, co
leży poza bazą, po usunięciu, są przekazywane właśnie tak.
Ręczne odpalenie triggera jest tu z drugiego powodu, który warto nazwać: POST
/agents/{id}/triggers/{id}/run kiedyś czekał na run, który uruchomił, więc
agent wolniejszy niż limit czasu odczytu proxy odpowiadał 504, podczas gdy run
trwał dalej i się zatwierdzał - błąd zgłoszony dla czegoś, co działało, i
zaproszenie, żeby nacisnąć przycisk jeszcze raz (#658). Trasa odpowiada
202, gdy zadanie jest już w kolejce.
Usunięcie, którego dane wejściowe niszczy jego własny commit, kolejkuje
sprzątanie w tym commicie. Usunięcie organizacji przekazuje dalej ścieżki w
magazynie i nazwy kolekcji, których ostatni zapis usuwa jego transakcja, więc
queue_external_state_cleanup wpisuje je do zadań sprzątających w tej samej
sesji: awaria między commitem a sprzątaniem niczego nie gubi, a zadanie jest
ponawiane, dopóki pliki i kolekcje wektorowe nie znikną.
Praca, którą można stracić, idzie przez spawn_after_commit
(app/core/background.py): krótka praca bez gwarancji w pętli samego API, jak
zapisanie osiągalności serwera MCP albo usunięcie plików, do których artefakt już
się nie odwołuje. Kolejkuje korutynę na sesji i uruchamia ją dwie instrukcje po
commit(); kolejność jest udowodniona na prawdziwej bazie w
tests/integration/test_flow_starts_after_commit.py. Przy zamknięciu lifespan
czeka na background.drain(), zanim zlikwiduje to, co te taski czytają.
Z tego, gdzie leży ta kolejka, wynikają dwie rzeczy:
- Należy do sesji, nie do żądania. Serwis, który przekazuje pracę, nie musi
wiedzieć, czy wywołano go z trasy, handlera WebSocketu, CLI czy workera - i
dlatego to nie są
BackgroundTasksz FastAPI, których gwarancja dotyczy odpowiedzi, a tamte trzy miejsca jej nie mają. - Wycofana transakcja niczego nie zleca. Zakolejkowane korutyny są zamykane, bo wykonanie pracy, której wiersz wyrzucono, tylko przenosi błąd w miejsce trudniejsze do wyjaśnienia.
spawn zostaje właściwy dla pracy, która ma wszystko, czego potrzebuje, i nie
dotyka żadnego wiersza - webhooki kanałów przekazują mu każde dostarczenie, żeby
provider dostał odpowiedź od razu. Żadne z nich nie przetrwa restartu; zadanie
tak. Zob. Dodaj zadanie w tle.
Runy agenta: capability nigdy nie pobiera¶
Powyższy podział na warstwy ma wewnątrz runu agenta jeszcze jedną zasadę i to
przez nią runner jest tak duży, jak jest. Capability nie dotyka bazy danych.
Wszystko, czego z niej potrzebuje — nazwy kolekcji, które wiąże jej spec, skille,
które może wczytać, workspace, do którego pisze, delegaci, których może wywołać —
jest rozwiązywane przez serwis przed startem runu i przekazywane jako
resources, słownik, który capability może czytać i do którego nie może nic
dodać. Model pyta o to, czego szukać; nigdy nie dowiaduje się, gdzie.
Dwa wpisy w tym słowniku są szwami do innych podsystemów, a nie zwykłymi danymi:
| Zasób | Zostawiony przez runner | Czytany przez |
|---|---|---|
WORKSPACE_RESOURCE |
otwarty workspace, który run dostaje też jako ctx.workspace |
tool_output_limits oraz delegat, który współdzieli sandbox |
SUBAGENT_RUNTIME_RESOURCE |
rozwiązane drzewo delegacji | capability subagents |
Delegacja jest najostrzejszym przypadkiem tej zasady. Delegat jest wierszem; tak
samo jego przypięta wersja, jego kolekcje, jego skille i jego sekrety, a każde
z nich musi przejść przez resolve_access, zanim zostanie odczytane. Runner
obchodzi więc całe drzewo — zagnieżdżenie, ograniczenie głębokości, odmowę dla
delegata już działającego wyżej w tym samym runie — póki wciąż trzyma sesję
i kontekst uwierzytelnienia, i zostawia po sobie domknięcia budujące już
rozwiązanego agenta oraz rejestrator zapisujący jeden wiersz. W czasie działania
dzieje się praca procesora i Pydantic AI.
Odwrotnie być nie może: AsyncSession żądania jest dzielona przez wszystko
w runie i nie jest bezpieczna przy współbieżności, więc drzewo obchodzone
w czasie działania byłoby zapytaniem z wnętrza wywołania narzędzia —
a rozgałęzienie byłoby kilkoma takimi naraz, co nie tyle spowalnia, ile psuje
sesję używaną przez resztę żądania.
Brak zasobu nigdy nie jest błędem. Podgląd, test jednostkowy albo agent, któremu usunięto wszystkich delegatów, nie rozwiązuje niczego, a capability nie oferuje wtedy żadnych delegatów, zamiast podnosić wyjątek — dokładnie tak, jak capability workspace'u spada do backendu trzymanego w pamięci.
Schemat¶
0007_delegated_runs dodaje dwie kolumny do agent_runs.
parent_run_id to samoodwołujący się klucz obcy mówiący, który run
oddelegował ten, i to on pilnuje uczciwości miesięcznej sumy organizacji — zobacz
Governance.
Jest ON DELETE SET NULL z tej samej arytmetyki: usunięcie rodzica kasuje
wiersz, który zawierał ten koszt, więc wiersz delegacji, który staje się wierszem
najwyższego poziomu, powinien zacząć się liczyć. Kaskada skasowałaby zapis
wydanych pieniędzy.
subagent_task_id to własne id zadania z biblioteki delegacji, które łączy
wiersz z uchwytem, jaki model rodzica widział w swoim transkrypcie. Ponieważ
klucz obcy może wyzerować tylko własną kolumnę, ten uchwyt przeżywa usunięcie
i jest wstrzymywany przez AgentRunRead — zamiast być zerowany przez trigger na
najgorętszej pod względem wstawień tabeli w schemacie.
Indeks na parent_run_id obsługuje list_runs(parent_run_id=...), czyli to,
o co pyta GET /runs?parent_run_id=. Zobacz
Governance, żeby dowiedzieć się, dlaczego
historia runów nigdy nie wypisuje obu rodzajów wierszy razem.
Usuwanie członka albo tenanta¶
Kilka kluczy obcych wymusiłoby przy usuwaniu dokładnie ten zapis, którego
zabrania ograniczenie CHECK — więc kaskada deklarowana przez schemat
i deklarowany przez ten sam schemat niezmiennik są ze sobą sprzeczne, a usunięcie
podnosi wyjątek wewnątrz bazy jako 500, zamiast cokolwiek zrobić. Trzy takie pary
są godzone w serwisie, zanim wiersz zniknie, wewnątrz własnej transakcji żądania:
- Prywatny sekret osoby odchodzącej.
organization_secrets.owner_user_idjestSET NULL, aleck_secret_private_needs_ownerzabrania prywatnego sekretu bez właściciela.UserService.deletenajpierw podnosi prywatne sekrety odchodzącego do widoczności organizacji, więc null zapisywany przez kaskadę jest legalny, a klucz pozostaje osiągalny dla organizacji, zamiast zostać osierocony. - Organizacje twórcy.
organizations.created_by_user_idjestRESTRICT, a rejestracja domyślnie tworzy organizację osobistą, więc gołeDELETE usersnigdy nie zadziałało dla prawdziwego konta. Organizacja osobista jest usuwana razem z właścicielem; współdzielona jest przekazywana innemu właścicielowi, a gdy nie ma komu jej przekazać, usunięcie zostaje odrzucone. - Kolekcja o zasięgu organizacji.
knowledge_bases.organization_idjestSET NULL, aleck_knowledge_bases_org_scope_has_orgzabrania wiersza o zasięgu organizacji bez organizacji.OrganizationService.deleteusuwa kolekcje o zasięgu organizacji jawnie — razem z tabelą wektorów — zanim zniknie wiersz organizacji; kolekcja osobista, która jedynie nosi id organizacji, zostaje oddanaSET NULL, na co pozwala jej zasięg. Ponieważ usunięcie tabeli wektorów potrzebuje magazynu o zasięgu żądania, route usuwania podłącza go przez dedykowaną zależność; każdy inny route organizacji używa zwykłego serwisu i nie buduje magazynu, którego nigdy by nie dotknął.
Co run przekazał swojemu modelowi i dlaczego jest to tabela¶
run_manifests trzyma po jednym wierszu na run: instrukcje w postaci, w jakiej
je złożono i wysłano, każdą definicję narzędzia w postaci, w jakiej dostał ją
provider, ustawienia, po jednym wpisie na żądanie do modelu oraz listę wiadomości
ostatniego żądania. Zapisuje go AgentRunnerService.finish na każdej drodze
wyjścia z runu, a czyta GET /runs/{id}/manifest — zobacz
Koncepcje, żeby dowiedzieć się,
co jest zapisywane i dlaczego nie da się tego odtworzyć ze speca.
Trzy decyzje o warstwach warto zapisać, bo każda z nich to miejsce, w którym oczywista alternatywa jest błędna.
Tabela, a nie kolumna w agent_runs. Ta tabela jest najczęściej wypisywaną
w produkcie — historia runów, zakładka wydatków, liczby na dashboardzie, eksport
do CSV — a dokument JSONB trzymający schemat JSON każdego narzędzia byłby przez
nie wszystkie czytany po to, by odpowiedzieć na pytanie, którego żadna z nich nie
zadaje. Jeden wiersz na run, ON DELETE CASCADE i od runu, i od organizacji,
czytany wyłącznie przez widok szczegółów.
Zapisywanie dzieje się w app/agents/manifest.py, a nie w serwisie. Model,
na którym zbudowany jest agent, jest opakowany (RecordingModel, czyli
WrapperModel — ten sam kształt, którego MeteredModel używa do księgowania
wydatków subagenta), więc zapisywane jest ModelRequestParameters w postaci,
w jakiej otrzymał je provider: po każdym hooku prepare, po tym, jak
wyszukiwanie narzędzi ukryło to, co ukrywa, po dodaniu narzędzia wyjściowego.
Serwis utrwala to, co zebrał wrapper, i nie decyduje o zawartości.
Załącznik w transkrypcie odczytuje się przez run, a nie przez tego, kto go wgrał.
GET /files/{id} jest ograniczone do ChatFile.user_id, co jest właściwym
zasięgiem dla okna pisania wiadomości i niewłaściwym dla przeglądu runu. Odczyt
runu jest prawem organizacji, a nie tego, kto go uruchomił, więc karty
załączników w transkrypcie kolegi renderowały się, a każdy podgląd odpowiadał
404.
GET /runs/{run_id}/files/{file_id} autoryzuje tak jak transkrypt —
organizacja, potem runs:view — a następnie dopuszcza plik tylko tam, gdzie jego
message_id wskazuje turę rozmowy należącej do tego runu. Dokładnie tyle
zasięgu, ile daje już transkrypt, i ani trochę więcej.
Oba route'y podają bajty przez _chat_file_bytes.py, więc to, co przeglądarka
może wyświetlić, nie zależy od tego, który z nich autoryzował odczyt.
Zapis jest osłonięty i zagnieżdżony. Dochodzi się do niego z bloku
finally, więc wyjątek podniesiony podczas zapisywania nieudanego runu
zastąpiłby tę awarię sobą. Samo połknięcie go nie wystarczy: nieudany flush
zostawia sesję nie do użytku, więc własny końcowy zapis runu przepadłby na rzecz
zapisu, o który nikt nie prosił. Wykonuje się wewnątrz begin_nested() z tego
samego powodu co TranscriptService._attach — to SAVEPOINT sprawia, że „ten
zapis może się nieszkodliwie nie powieść” jest prawdą, a nie pobożnym życzeniem.
Odmowa, która nazywa pole¶
Każda odmowa wychodzi w jednej kopercie,
{"error": {"code", "message", "details"}}, a odmowa dotycząca pola nazywa je
w jednym kształcie:
{"details": {"fields": [{"field": "spec.name", "message": "String should have at most 128 characters"}]}}
fieldProblems w frontend/src/lib/api-error.ts czyta właśnie to i nic więcej,
i to dzięki temu formularz może oznaczyć wadliwe pole, zamiast pokazywać zdanie,
którego czytelnik musi szukać, przeglądając stronę od nowa.
app/core/field_errors.py to jedyne miejsce, w którym się ją buduje, i ma trzy
punkty wejścia. Dwa z nich czytają Pydantica, a to, którym wywołującym jesteś,
decyduje, co znaczy pierwszy element loc:
| Dla | loc zaczyna się od |
|
|---|---|---|
request_field_problems |
validation_exception_handler, każdy RequestValidationError |
miejsca, z którego przyszła wartość (body, query, …) — i to jest odrzucane |
field_problems(…, root=…) |
serwis walidujący dokument, którego nie potrafi schemat route'a — nadpisanie ustawień ingestii dla jednego wgrania, ręcznie edytowany YAML speca, blob konfiguracji capability | pola tego dokumentu, raportowanego pod root |
refused_field(field, message, **context) |
zasada, którą serwis wyraża prozą, a nie modelem — endpoint niosący hasło, bot Mattermosta tracący swój serwer, dokument YAML, który nigdy się nie sparsował | — zwraca BadRequestError, który wywołujący ma podnieść |
refused_field nazywa zdanie raz, bo message koperty i zdanie pola to jedno
i to samo zdanie; podnoszący, który potrzebuje innego statusu, buduje te same
details przez field_details. Osiemnaście miejsc wywołania odpowiadało zamiast
tego details={"field": "<name>"}, w liczbie pojedynczej, ze zdaniem w kopercie,
i żaden formularz nigdy tego nie przeczytał — ta sama wada w trzecim kształcie
(#891). Czwartym zapisem
było details={"<field>": <value>}, gdzie kluczem była nazwa pola, a wartością
to, co wywołujący właśnie przysłał: model_profile.py odpowiadał na odrzucone id
modelu tym właśnie id, w ciele odpowiedzi i w wierszu logu obok
(#898).
Decydowanie na podstawie samego łańcucha znaków źle odczytałoby speca, którego
zakazany klucz najwyższego poziomu nazywa się dosłownie body, czyli jeden
kształt stojący za dwa — dokładnie ten błąd, z którym ten moduł ma skończyć.
Zanim dodasz miejsce wywołania, warto znać jeszcze dwie własności. Czyta
wyłącznie loc i msg, więc odrzucona wartość nie może wrócić obok pola, które
zepsuła — i dlatego te miejsca wywołania podają jej exc.errors() bez
filtrowania. A root to nazwa, którą formularz wywołującego daje całemu
dokumentowi, więc każda ścieżka jest względem niego: to daje
model_validator(mode="after") miejsce, w którym może wylądować — zgłasza
loc: (), bo zasada, którą złamał, dotyczy dwóch pól naraz — i sprawia, że
punkty wejścia się zgadzają, a nadpisanie odrzucone przy wgrywaniu nazywa
dokładnie to, co nazywa 422, gdy ta sama para przychodzi jako własne ustawienia
kolekcji.
Przepuszczanie zamiast tego własnego exc.errors() Pydantica było
#882 — drugim kształtem,
niosącym input, ctx i url, którego nic na frontendzie nie czytało.
Zagregowana odmowa niesie obie połowy.
validate_spec zgłasza wszystkie problemy speca naraz, a większość z nich to
zepsute odwołania, przy których nie ma czego oznaczyć. Odpowiada więc
details.problems — po wierszu na każdy, które Builder wypisuje — oraz
details.fields dla tego podzbioru, który pole nazywa.
Konfiguracja capability to jedyna część speca renderowana jako generowany
formularz, więc jej odmowy nazywają pole:
capabilities.knowledge.config.default_top_k, z specialists.researcher.
z przodu dla capability skonfigurowanej wewnątrz delegata, bo Builder renderuje
po jednym formularzu na specjalistę.
Zachowanie samego zdania było drugą połową #882. Zapis szkicu w ogóle nie waliduje schematu konfiguracji, więc walidacja przy publikacji to jedyne miejsce, w którym błędnie wpisane ustawienie zostaje kiedykolwiek odrzucone.
Dwa rodzaje odmowy celowo nie nazywają żadnego pola, a granica między nimi a resztą jest tym, co powstrzymuje jeden kształt przed ponownym znaczeniem dwóch rzeczy:
- Odmowa dotycząca wartości, której nie przysłał żaden wywołujący. Nazwę
zdalnego pliku wybiera ten, kto może wrzucić plik do synchronizowanego folderu,
a oba sprawdzenia w
app/services/rag/remote_names.pywykonują się wewnątrz synchronizacji w tle, gdzie czytelnikiem jest log, a nie formularz. Tak samo przy źródle Google Drive odczytanym bez swojego poświadczenia: wiersz jest zapisany, a odmawia go przy route'cievalidate_configkonektora, wyprowadzone z jegoCONFIG_MODEL. - Konflikt.
AlreadyExistsErrorzgłasza fakt o wierszu, który już istnieje, a nie o kształcie tego, co przysłano — a to, które z pól formularza wyprodukowało zajętą wartość, wie tylko formularz, bo uchwyt agenta wyprowadza się z nazwy, której nikt nie wpisał jako uchwytu. Bierze to na siebieidentifiedByzsubmitFailurepo stronie klienta, więc 409 niesie zajętą wartość i żadnego pola.
Kluczowe pliki¶
- Punkt wejścia:
app/main.py - Konfiguracja:
app/core/config.py - Zależności:
app/api/deps.py - Narzędzia uwierzytelniania:
app/core/security.py - Handlery wyjątków:
app/api/exception_handlers.py - Odmowy na poziomie pola:
app/core/field_errors.py
Uwierzytelnianie i autoryzacja¶
Metody uwierzytelniania¶
Projekt wspiera dwie metody uwierzytelniania, obie zawsze dostępne:
- JWT (JSON Web Tokens) -- Używane przez frontend i klientów API.
- Logowanie przez
POST /api/v1/auth/loginzwracaaccess_token+refresh_token. - Tokeny dostępu wygasają po
ACCESS_TOKEN_EXPIRE_MINUTES(domyślnie 30 min). - Tokeny odświeżające wygasają po
REFRESH_TOKEN_EXPIRE_MINUTES(domyślnie 7 dni). - Frontend trzyma tokeny jako ciasteczka HTTP-only.
-
Uwierzytelnienie WebSocketu przekazuje JWT jako parametr zapytania (
?token=<jwt>) albo w ciasteczku. -
Klucz API -- Używany do dostępu serwer-serwer i programistycznego.
- Przekazywany nagłówkiem
X-API-Key(konfigurowalnym przezAPI_KEY_HEADER). - Jeden współdzielony klucz ustawiany zmienną środowiskową
API_KEY. - Używa porównania w stałym czasie (
secrets.compare_digest), żeby zapobiec atakom czasowym.
Gdzie ląduje świeża sesja¶
Sesję ustanawiają trzy drzwi, trzema drogami — formularz z hasłem, callback OAuth
i magic link — a o tym, gdzie odwiedzający wyląduje, decyduje dokładnie jedno
miejsce: postSignInDestination w frontend/src/lib/auth-landing.ts, które
respektuje deep link tylko wtedy, gdy jest ścieżką w tym samym origin, a poza tym
odpowiada dashboardem. Trzy odpowiedzi w trzech miejscach to rozjazd, a ten
rozjazd zdarzył się naprawdę dwa razy: na osi ról, gdzie lądowanie rozwidlało się
według roli, i na osi providerów, gdzie podróż w obie strony przez OAuth gubiła
?returnTo=.
Między drzwiami różni się tylko to, jak ścieżka podróżuje:
| Drzwi | Jak ścieżka dociera do lądowania |
|---|---|
| Formularz z hasłem | nigdy nie opuściła karty — czytana wprost z ?returnTo= |
| Callback OAuth | sessionStorage, co jest dozwolone, bo podróż w obie strony zaczyna się i kończy w tej samej karcie w tym samym origin |
| Magic link | podpisane oświadczenie w tokenie, bo w link klika się z maila — w innej karcie, często w innej aplikacji, gdzie sessionStorage jest pusty z definicji |
Ścieżka magic linku zostaje odrzucona już przy żądaniu, a nie odfiltrowana
przy lądowaniu: MagicLinkRequest.return_to przyjmuje ścieżkę na tym
deploymencie i nic ze schematem, z drugim wiodącym ukośnikiem, z odwrotnym
ukośnikiem ani ze znakiem sterującym, więc token, który dałoby się zmusić do
przeniesienia dowolnego łańcucha znaków, po prostu nie powstaje. Lądowanie i tak
ocenia ją ponownie — sprawdzenie, które wykonuje się raz, na serwerze, na
wartości wędrującej potem przez maila, to sprawdzenie, co do którego klient nie
może zakładać, że się odbyło.
POST /auth/magic-link/verify odpowiada więc przez MagicLinkToken — parę
tokenów plus return_to, niezastosowane. Własny schemat, a nie nullowalne pole
w Token, bo pozostałe trzy odpowiedzi z tokenami nie mają ścieżki powrotnej do
przeniesienia, a pole, które w większości z nich jest zawsze nullem, klient
szybko uczy się ignorować.
Autoryzacja¶
Na użytkowniku nie ma kolumny z rolą ani zależności route'a opartej na roli. To,
co członek może robić wewnątrz organizacji, jest uprawnieniem z katalogu
w app/core/permissions.py, a to, których wierszy może dotknąć, rozstrzyga się
per wiersz — zobacz Uprawnienia, żeby poznać cały model.
Dwie zależności, i tylko dwie:
| Alias | Znaczy |
|---|---|
CurrentUser |
każdy uwierzytelniony użytkownik |
CurrentAppAdmin |
superadmin deploymentu (users.is_app_admin), dla /admin/* i zbiorczych route'ów /rag |
Wszystko inne idzie przez jedno z:
# A permission, on a collection route.
@router.post("/agents", dependencies=[Depends(require(Perm.AGENTS_EDIT))])
async def create_agent(...): ...
# A permission on one row, resolved in the service.
if not await resolve_access(db, ctx, agent, Perm.AGENTS_EDIT, resource_type=AGENT):
raise AuthorizationError(...)
# A permission decided by a parameter, resolved in the service: scope=org
# demands runs:view, scope=own only a signed-in caller. See Permissions,
# "Where the gates go".
return await service.usage(ctx, scope=scope, ...)
require(...) nie należy do route'a działającego na jednym zasobie
Bramka rolowa nie widzi grantów na wierszu, więc odrzuciłaby Viewera
z jawnym grantem edit, zanim resolve_access zdążyłoby poszerzyć jego
dostęp. Ten sam kształt obowiązuje, gdy o pytaniu decyduje parametr —
GET /stats/usage?scope=own musi być osiągalne dla zwykłego członka, więc
jego bramka mieszka w serwisie. tests/api/test_platform_routes.py pilnuje
całości.
Osobista preferencja nie niesie żadnej bramki
Wiersz o zasięgu (user_id, organization_id), który czyta i zapisuje
wyłącznie jego właściciel, nie jest danymi organizacji, więc nie bramkuje go
żadne uprawnienie i nie ma route'a sięgającego do cudzego.
GET/PUT/DELETE /me/dashboard-layout (zapisany układ dashboardu)
i leżąca pod nim półka /presets (nazwane układy, między którymi ktoś się
przełącza) są tym wzorcem: CurrentUser + ActiveOrg, każde zapytanie
filtrowane po obu id. Klucz złożony jest całą granicą tenanta — układ albo
preset zapisany w jednej organizacji jest niewidoczny w innej nawet dla
swojego właściciela, co sama kontrola per użytkownik by przepuściła, więc
tests/integration/test_dashboard_layout.py
i tests/integration/test_dashboard_preset.py pokrywają dokładnie to. Nie ma
route'a zastosuj preset: zastosowanie go to PUT klienta wpisujący wpisy
presetu jako aktywny układ, dzięki czemu dashboard zachowuje jedną ścieżkę
zapisu i jedną walidację tego, co renderuje.
Umieszczenie karty może też nieść options — jej własne okno czasowe
(period), sposób prezentacji (style) i zawężenie (agent_id, user_id).
Zapisana opcja jest prośbą, nigdy autoryzacją: dociera do
GET /stats/usage jako parametr zapytania i tam zostaje odrzucona, jeśli
wywołujący nie może czytać tego, o co prosi — dokładnie tak, jak gdyby sam
wpisał ten URL. Zawężenie do kolegi to czytanie cudzych wierszy, więc jest to
scope=org i stoi za runs:view; scope=own z user_id daje 422, a nie
ciche przeinterpretowanie. Przy zapisie styl i okno są walidowane wobec
zamkniętych zbiorów deklarowanych przez rejestr frontendu
(tests/test_dashboard_registry.py pilnuje równości obu odbić); przy odczycie
opcje wracają dosłownie, bo agent, który został od tamtej pory usunięty, nie
może pociągnąć za sobą całego układu.
UserRole, User.has_role(), RoleChecker, CurrentAdmin i
CurrentSuperuser były modelem z szablonu i zniknęły, razem z kolumną
users.role, która odeszła wraz ze zgnieceniem migracji w 0001_baseline. Były trzecią odpowiedzią na pytanie,
które miało już dwie.
Ochrona przed IDOR¶
Dwa predykaty i nie są wymienne. Organizacja ogranicza odczyt; użytkownik zawęża go dalej.
- Endpointy rozmów przekazują
organization_id=active_org.id. Bez tego rozmowa jest wyszukiwana wyłącznie po kluczu głównym, a każdy zalogowany wywołujący, który zna UUID, czyta rozmowę w innym tenancie — albo do niej dopisuje. - Przekazują też
user_id=current_user.id, co ogranicza wiersz do jego właściciela albo do kogoś, komu go udostępniono. Sama kontrola tenanta nie wystarcza: bez tego każdy członek organizacji może czytać rozmowy każdego innego członka i do nich dopisywać. - Udostępnienie niesie zapis dopiero na poziomie
edit. Odczyt i zapis to dwa pytania —_may_readi_may_write— a udostępnienie odpowiadało kiedyś na oba, niezależnie od poziomu, który niosło, więc dwa poziomy oferowane w oknie udostępniania znaczyły to samo: rozmowę udostępnioną do podglądu można było przemianować, zarchiwizować, usunąć albo dopisać do niej turęrole: "assistant", którą wszyscy czytają w/chat, a model dostaje z powrotem jako własne słowa. Poziom jest komunikowany temu, kto go nadaje, więc egzekwowany jest właśnie ten poziom (#931). - W
transcriptten jeden argument robi dwie rzeczy — autoryzuje i wzbogaca każdą wiadomość o własną ocenę wywołującego. To przeciążenie jest powodem, dla którego jego autoryzująca połowa tak długo była nieobecna: route go przekazywał, argument był w przeglądzie kodu wyraźnie widoczny, a robił tę drugą robotę. - Pobieranie plików sprawdza
chat_file.user_id == current_user.id, a dołączenie pliku do wiadomości niesie tego samego właściciela wWHERE: tura wskazująca id pliku innego użytkownika — albo plik już przypięty do wiadomości — zostaje odrzucona, nigdy po cichu zastosowana.
ConversationService sprawia, że tego rozróżnienia nie da się pominąć:
organization_id jest wymaganym argumentem nazwanym typu UUID przy każdym
odczycie i zapisie rozmowy. Kiedyś miał wartość domyślną None, None znaczyło
„bez zasięgu”, a pominięcia nie da się odróżnić od zamiaru — dwa route'y
obsługujące zwykłych członków po prostu go nie podawały i każdy zalogowany
użytkownik mógł czytać dowolną rozmowę w deploymencie i do niej dopisywać.
Ulubione należy do czytelnika, a nie do wątku¶
conversation_favourites to wiersz na parę (user_id, conversation_id), a nie
wartość logiczna w conversations, bo rozmowę można udostępnić, a wątek w kanale
ma uczestników, a nie właściciela: kolumna pozwoliłaby gwiazdce jednej osoby
decydować o tym, gdzie wątek siedzi u wszystkich, którzy go widzą.
Cztery konsekwencje, które warto znać:
POST/DELETE /conversations/{id}/favouritesą autoryzowane jak odczyt. Gwiazdka mówi, gdzie wątek siedzi we własnym panelu bocznym osoby, która ją postawiła, i nie zmienia w wątku nic, więc ktoś, komu rozmowę udostępniono, może ją oznaczyć dokładnie tak jak jej właściciel.for_writeodmówiłoby tu właśnie temu czytelnikowi, dla którego ta funkcja istnieje. Oba route'y niosąAuthz tego samego powodu co każdy inny odczyt rozmowy: bez kontekstu_may_read_trigger_logodpowiada fałszem, a log przebiegów triggera, który wywołujący może otworzyć dziękiruns:view, byłby czymś, czego nie mógłby oznaczyć gwiazdką (#1254).is_favouritenależy do wywołującego i jest stemplowane wget_conversation— jedynym odczycie, przez który przechodzi każdy odczyt o zasięgu czytelnika, a nie przy każdym route'cie. Docierało do dwóch odpowiedzi na osiem, dopóki każdy route musiał o tym pamiętać, więcGETalbo PATCH mówił komuś, kto oznaczył wątek gwiazdką, że tego nie zrobił (#1254). Odczyt bez czytelnika — listowanie dla admina, ścieżka runu rozwiązująca wątek — nie pyta o niczyje gwiazdki i nie płaci za to zapytaniem, a odczyt, który wyłącznie autoryzuje, wyłącza to jawnie przezinclude_favourite=False. To są te odczyty, których wynik zostaje odrzucony albo nie jest rozmową:GET /conversations/{id}/messages, który rozwiązuje wątek raz, przeztranscript, dla strony i jej kosztu; trzy route'y workspace'u; każda tura istniejącego czatu, przezagent._resolve_in_org; oraz zapisy —add_message,delete_conversationiset_favourite, który sam nadpisuje tę flagę. Domyślne włączenie jest tym, co powstrzymuje route, który faktycznie serializuje rozmowę, przed zapomnieniem; wyłączenie jest świadomym aktem w miejscu wywołania.- Oznaczanie gwiazdką jest idempotentne przy rywalizacji, bo wstawienie jest
ON CONFLICT DO NOTHING, a nie odczytem i wstawieniem po nim. Dwa nakładające się POST-y dla tej samej pary nie widziały wiersza i drugi naruszał klucz główny; klient dodatkowo szereguje własne oczekujące oznaczenie per rozmowa, więc podwójne kliknięcie nie może doprowadzić do odpowiedzi na DELETE przed POST-em, po którym nastąpił. - Pasmo to
ORDER BY, a nie grupowanie strony. Panel boczny jest stronicowany, więc ulubiona rozmowa wysortowana świeżością na drugą stronę siedziałaby pod pięćdziesięcioma wątkami, które ulubione nie są. Wewnątrz każdego pasma wybrane sortowanie nadal obowiązuje, a widok archiwum nie jest podzielony na pasma w ogóle: gwiazdka przeżywa archiwizację, ale pasmo wewnątrz archiwum byłoby drugim miejscem, w którym trzeba szukać tego, co archiwizacja właśnie przeniosła.
Nie ma już sposobu, by przeczytać rozmowę w poprzek tenantów. Wartownik, który
kiedyś to zapisywał (UNSCOPED), miał dokładnie jednego wywołującego,
/admin/conversations/{id}, i oba odeszły razem z przeglądarką rozmów
obejmującą cały deployment — Activity odpowiada na „co się stało” kosztem,
modelem, trace'em i tym, co obok podano modelowi, a to jest pytanie, do którego
tamten ekran był używany. To, co z niego zostało, to
GET /admin/conversations?user_id=: wątki jednego wskazanego konta, wypisywane
dla szuflady użytkownika w panelu admina i nigdy nieczytane.
O co pyta szuflada użytkownika w panelu admina¶
GET /admin/users/{id}/detail jest osobnym route'em, a nie polami na
GET /admin/users/{id}, bo jest widokiem złożonym z trzech tabel — członkostw,
sesji i wiersza użytkownika — a użytkownika czyta się w kilkunastu miejscach,
z których żadne tego nie potrzebuje.
Istnieje, bo szuflada nie odpowiadała na żadne z pytań, jakie naprawdę ma admin
otwierający wiersz: pokazywała id, adres e-mail już obecny w tabeli, nazwę
wyświetlaną i datę dołączenia (#942). Teraz odpowiada na to, gdzie ta osoba ma
dostęp i z jaką władzą, kiedy ostatnio tu była i czy cokolwiek jej jest wciąż
zalogowane. last_seen_at jest nullem, a nie polem nieobecnym dla konta,
które nigdy się nie zalogowało, bo „utworzone i nigdy nieużyte” oraz „uśpione od
marca” to różne decyzje.
Cały route jest CurrentAppAdmin: każde pole w nim dotyczy kogoś innego.
Pełne uprawnienia na poziomie endpointów znajdziesz w docs/permissions.md.
Przetwarzanie plików w czacie¶
Gdy użytkownik wgra plik w interfejsie czatu, wykonuje się następujący pipeline:
Upload (POST /files/upload)
-> Validate (MIME type + size)
-> Classify (image / pdf / docx / text)
-> Parse (extract text content)
-> Store (save to media/{user_id}/)
-> Record (create ChatFile in DB)
-> Link (attach to message when sent)
Wspierane typy plików¶
| Kategoria | Rozszerzenia | Przetwarzanie |
|---|---|---|
| Obrazy | JPEG, PNG, WebP, GIF | Zapisywane bez zmian, wysyłane do LLM jako dane binarne do analizy obrazu |
| Tekst wyciągany skonfigurowanym parserem | ||
| Dokumenty | .docx | Tekst wyciągany przez python-docx |
| Tekst | .txt, .md | Dekodowane wprost jako UTF-8 |
Wybór parsera¶
Załączniki czatu są czytane przez PyMuPDF i nie da się tego skonfigurować: załącznik nie należy do żadnej kolekcji, więc nie ma zapisanej konfiguracji, z której można by odczytać wybór parsera. Wybór parsera dotyczy kolekcji wiedzy, gdzie jest ustawieniem per kolekcja.
Przechowywanie¶
Pliki są zapisywane do media/{user_id}/ przez FileStorageService. Model
ChatFile przechowuje storage_path, filename, mime_type, size,
file_type oraz parsed_content (wyciągnięty tekst). Do swoich plików ma dostęp
wyłącznie ich właściciel.
Limity rozmiaru¶
Są dwa, bo są dwie powierzchnie. MAX_UPLOAD_SIZE_MB (domyślnie 50 MB) to limit
dokumentu w bazie wiedzy; CHAT_MAX_UPLOAD_SIZE_MB (domyślnie 10 MB) to tyle,
ile można załączyć w czacie. Są osobnymi ustawieniami, a nie jednym, bo dokument
jest dzielony na fragmenty i odczytywany z powrotem przez retrieval, podczas gdy
załącznik do agenta bez workspace'u jest wklejany w całości do promptu — ten sam
rozmiar zawodzi w każdym z tych przypadków inaczej. GET /api/v1/health
publikuje oba.
System RAG¶
Przegląd architektury¶
System RAG (Retrieval Augmented Generation) dostarcza bazę wiedzy, którą agent AI może przeszukiwać w trakcie rozmów. Składa się z:
Documents -> Parse -> Chunk -> Embed -> Vector Store
|
User Query -> Embed -> Search -> Rerank? -> Results -> Agent Prompt
Zasada kluczowa: RAG jest globalny¶
Kolekcje są współdzielone przez WSZYSTKICH użytkowników. Nie ma izolacji dokumentów per użytkownik. Oznacza to, że:
- Każdy uwierzytelniony użytkownik może przeszukiwać dowolną kolekcję.
- Tylko administratorzy mogą tworzyć i usuwać kolekcje, wgrywać dokumenty, konfigurować źródła synchronizacji i oglądać logi synchronizacji.
- Baza wiedzy służy jako zasób współdzielony w obrębie całej organizacji.
Komponenty¶
| Komponent | Plik | Do czego służy |
|---|---|---|
DocumentProcessor |
rag/documents.py |
Parsuje pliki do tekstu (PDF, DOCX, TXT, obrazy) |
IngestionService |
rag/ingestion.py |
Orkiestruje parse -> chunk -> embed -> store |
RetrievalService |
rag/retrieval.py |
Obsługuje zapytania wyszukiwania z filtrowaniem i punktacją |
EmbeddingService |
rag/embeddings.py |
Generuje embeddingi przez skonfigurowanego providera |
BaseVectorStore |
rag/vectorstore.py |
Abstrakcyjny interfejs operacji na bazie wektorowej |
PgVectorStore |
rag/vectorstore.py |
Implementacja na pgvector (PostgreSQL) |
Pipeline ingestii¶
Dokumenty można wciągnąć przez:
- CLI --
uv run agenticos cmd rag-ingest <path> - API --
POST /api/v1/rag/collections/{name}/ingest(tylko admin, wgranie pliku) - Źródła synchronizacji -- Skonfigurowane konektory (Google Drive, S3, strony internetowe), które pobierają dokumenty według harmonogramu albo na żądanie i usuwają to, czego ich źródło już nie zawiera.
Każdy wciągnięty dokument:
- Jest parsowany do tekstu (parser wybierany per kolekcja, do nadpisania per wgranie)
- Jest dzielony na fragmenty (chunk_size / chunk_overlap, też per kolekcja)
- Jest embedowany przez skonfigurowanego providera embeddingów
- Jest zapisywany w bazie wektorowej
- Jest śledzony w SQL przez model RAGDocument ze statusem (processing, done, error)
Tryby synchronizacji¶
| Tryb | Zachowanie |
|---|---|
full |
Zastępuje wszystkie dokumenty (wciąga wszystko od nowa) |
new_only |
Dodaje nowe pliki, wciąga od nowa pliki, których hasz treści się zmienił, pomija niezmienione |
update_only |
Wciąga od nowa wyłącznie zmienione pliki, nowe pomija całkowicie |
Konektory synchronizacji¶
Zdalne źródła dokumentów korzystają z wymiennych konektorów w
app/services/rag/connectors/. Każdy konektor implementuje BaseSyncConnector
z list_files() i _fetch(), deklaruje SECRET_KIND nazywające sekret
w vaulcie, który go uwierzytelnia, oraz deklaruje CONFIG_MODEL — model
Pydantic mówiący, jak znaleźć dokumenty, publikowany do kreatora jako JSON
Schema. download_file() jest
konkretne i decyduje, gdzie plik może wylądować. Zobacz docs/patterns.md, żeby
dowiedzieć się, jak dodać konektor, a docs/howto/add-sync-connector.md po
opracowany przykład.
Podsumowanie¶
- Route'y → serwisy → repozytoria. Route nigdy nie importuje repozytorium.
- Repozytorium używa
db.flush()idb.refresh(), nigdydb.commit(). Sesja żądania commituje raz, zanim odpowiedź zostanie zapisana. - Ścieżka runu agenta to główny usankcjonowany wyjątek: commituje przed
wywołaniem modelu i jeszcze raz w końcowym
finally.MLService._record_failureto ten drugi, z lustrzanego powodu — wiersz zużycia opisujący odmowę musi przetrwać wycofanie, które ta odmowa powoduje. - Dwa usankcjonowane wyjątki. Ścieżka runu agenta commituje przed wywołaniem
modelu i jeszcze raz w końcowym
finally;SessionService.detect_refresh_reusecommituje sesję, którą właśnie unieważnił, i wpis mówiący dlaczego — bo jego wywołujący natychmiast podnosi 401, a wycofanie cofnęłoby oba. - Praca w tle, która musi się wydarzyć, jest zadaniem kolejkowanym w
transakcji żądania (
enqueue); praca bez gwarancji, która czyta wiersz zapisany przez to żądanie, jest przekazywana przezspawn_after_commit, nigdy przezspawn. - Cienka domena to moduł; gruba to podpakiet z fasadą, a nic spoza niego nie importuje jego podmodułów.