Implement Chat#1
Conversation
| .idea/ | ||
| out/ | ||
| tmp/ | ||
| storage/ |
There was a problem hiding this comment.
| router.Post("/:id/documents", h.UploadSpaceDocument) | ||
| router.Get("/:id/documents/status", h.GetDocumentStatusStream) | ||
| router.Delete("/:id", h.DeleteSpace) | ||
| router.Delete("/:id/documents/:documentId", h.DeleteSpaceDocument) |
There was a problem hiding this comment.
Potential route conflict: wildcard /:id vs /status segment
Fiber matches routes top-down. /:id/documents/status is registered after /:id/documents (POST). If a GET /:id/documents/:documentId were ever added above it, status could be captured as a documentId. Current ordering is fine, but consider registering more-specific paths before less-specific ones or adding a constraint.
|
|
||
| router.Get("/:id/conversation", h.GetConversationList) | ||
| router.Post("/:id/conversation", h.CreateNewConversation) | ||
| } |
There was a problem hiding this comment.
REST convention: plural 'conversations' preferred
Other sub-resources use plural (documents). For consistency, consider /:id/conversations instead of /:id/conversation.
| } | |
| router.Get("/:id/conversations", h.GetConversationList) | |
| router.Post("/:id/conversations", h.CreateNewConversation) |
| AppName: "Default Example Go Project App", | ||
| BodyLimit: 10 * 1024 * 1024, | ||
| AppName: cfg.App.Name, | ||
| BodyLimit: 1024 * 1024 * 1024, |
There was a problem hiding this comment.
BodyLimit 1GB is excessive – DoS risk
1GB body limit exposes server to memory exhaustion / slowloris-style attacks. Use a sensible default (e.g. 10–50 MB) or make it configurable via cfg with an upper-bound guard.
| BodyLimit: 1024 * 1024 * 1024, | |
| BodyLimit: cfg.App.BodyLimitMB * 1024 * 1024, |
| app := fiber.New(fiber.Config{ | ||
| AppName: "Default Example Go Project App", | ||
| BodyLimit: 10 * 1024 * 1024, | ||
| AppName: cfg.App.Name, |
| type GenerateStreamChunk struct { | ||
| Content string | ||
| Done bool | ||
| InputTokens int |
There was a problem hiding this comment.
Err field in data struct
Embedding Err in the chunk struct couples error signaling with data. If this is sent over a channel, consumers must check Err on every receive. Consider using a separate error channel or a wrapper type to make the contract explicit. Not a bug, but worth documenting the expected consumer pattern.
| ) | ||
|
|
||
| type GenerateStreamChunk struct { | ||
| Content string |
There was a problem hiding this comment.
Token counts only meaningful when Done==true
InputTokens/OutputTokens likely only populated on the final chunk (Done==true). Add a doc comment clarifying when these fields are valid to prevent misuse.
| Content string | |
| // GenerateStreamChunk represents a single chunk in a streaming response. | |
| // InputTokens, OutputTokens, and Duration are only populated when Done is true. | |
| type GenerateStreamChunk struct { |
| import ( | ||
| "github.com/google/wire" | ||
| "github.com/miqdadyyy/gorago/internal/pkg/repository" | ||
| conversationrpi "github.com/miqdadyyy/gorago/internal/repository/conversation/implementation" |
There was a problem hiding this comment.
Inconsistent alias naming convention
conversationrpi uses rpi suffix while spacerpi also uses rpi, but usecase aliases use uci. The repo alias pattern rpi doesn't match a clear abbreviation (repository implementation → rpi vs ri). Consider aligning all aliases to a single convention, e.g., conversationri/spaceri or conversationrpi/spacerpi — currently consistent for repos but differs from the old spaceuc (now spaceuci). Minor readability concern only.
| @@ -259,6 +710,24 @@ | |||
| } | |||
| @@ -241,6 +673,25 @@ | |||
| } | |||
There was a problem hiding this comment.
Redundant conversation_id in SendMessageRequest body
The endpoint path is /api/conversation/{id}/message which already carries the conversation ID. Having conversation_id in the request body is redundant and could cause confusion if they differ. Consider removing it from the body schema or documenting precedence.
| ], | ||
| "responses": { | ||
| "200": { | ||
| "description": "OK", |
There was a problem hiding this comment.
POST create conversation should return 201
Creating a new conversation is a resource-creation operation. HTTP semantics recommend 201 Created rather than 200 OK.
| "description": "OK", | |
| "201": { | |
| "description": "Created", | |
| "schema": { | |
| "$ref": "#/definitions/github_com_miqdadyyy_gorago_internal_entity.CreateNewConversationResponse" | |
| } | |
| }, |
| @@ -17,7 +17,157 @@ | |||
| }, | |||
| "basePath": "/", | |||
| "paths": { | |||
| @@ -184,9 +462,109 @@ | |||
| } | |||
There was a problem hiding this comment.
SSE endpoint missing consumes field
The /api/space/{id}/documents/status GET endpoint does not declare a consumes field, which is fine for GET, but the sibling SSE endpoint /api/conversation/{id}/message (POST) declares consumes: application/json while producing text/event-stream. Ensure consistency—POST SSE endpoint should still validate JSON input but document that the response is not JSON.
| package entity | ||
|
|
||
| import ( | ||
| conversationucm "github.com/miqdadyyy/gorago/internal/usecase/conversation/models" |
There was a problem hiding this comment.
Entity layer depends on usecase layer
entity pkg imports from usecase (conversationucm). This inverts the typical dependency direction (usecase → entity). If usecase changes, entity must change. Move ConversationMessageItem to entity or a shared models pkg to keep entity at the bottom of the dependency graph.
| package entity | ||
|
|
||
| import ( | ||
| conversationucm "github.com/miqdadyyy/gorago/internal/usecase/conversation/models" |
There was a problem hiding this comment.
Import grouping
Standard library imports should be in a separate group from third-party imports per Go convention.
| conversationucm "github.com/miqdadyyy/gorago/internal/usecase/conversation/models" | |
| import ( | |
| "time" | |
| conversationucm "github.com/miqdadyyy/gorago/internal/usecase/conversation/models" | |
| ) |
|
|
||
| ProcessDocumentPageIngestionRequest struct { | ||
| SpaceDocumentID int64 `json:"space_document_id"` | ||
| PageNumber int `json:"page_number"` |
| @@ -51,11 +40,11 @@ log: | |||
| rotation: false | |||
|
|
|||
| storage: | |||
There was a problem hiding this comment.
Hardcoded credentials committed
Real access_key and secret_access_key values ('miqdad', 'monalisa123') are committed to source control. This is a security risk → rotate these credentials immediately and use environment variable references or placeholder values.
| storage: | |
| access_key: "${STORAGE_ACCESS_KEY}" | |
| secret_access_key: "${STORAGE_SECRET_ACCESS_KEY}" |
| provider: "ollama" | ||
| ollama: | ||
| host: "http://localhost:11434" | ||
| base_url: "http://192.168.1.23:11434" |
| ollama: | ||
| host: "http://localhost:11434" | ||
| model: "gemma4:e2b" | ||
| base_url: "http://192.168.1.23:11434" |
| max_tokens: 500 | ||
| overlap: 50 No newline at end of file | ||
| max_tokens: 800 | ||
| overlap: 150 No newline at end of file |
|
|
||
| // Create independent context — Fiber's request context may be cancelled | ||
| // when the handler returns, but SendStreamWriter runs asynchronously. | ||
| streamCtx, cancel := context.WithCancel(context.Background()) |
There was a problem hiding this comment.
Detached context won't propagate client cancellation to usecase
Using context.Background() means if the client disconnects before SendStreamWriter starts (or during channel setup), the usecase has no signal to stop. Consider deriving from a request-scoped context or wiring Fiber's close-notify into streamCtx.
| streamCtx, cancel := context.WithCancel(context.Background()) | |
| streamCtx, cancel := context.WithCancel(ctx.Context()) |
| _ = r | ||
| } | ||
| }() | ||
|
|
There was a problem hiding this comment.
|
|
||
| return ctx.SendStreamWriter(func(w *bufio.Writer) { | ||
| defer cancel() | ||
| defer func() { |
There was a problem hiding this comment.
Recover block is a no-op
Recovering the panic and assigning to _ hides bugs silently. At minimum log the recovered value so issues are observable in production.
| defer func() { | |
| defer func() { | |
| if r := recover(); r != nil { | |
| // TODO: replace with structured logger | |
| fmt.Printf("SendMessage stream panic: %v\n", r) | |
| } | |
| }() |
|
|
||
| res, errs := h.conversationUsecase.CreateNewConversation(ctx.Context(), req) | ||
| if errs != nil { | ||
| return response.New(errs.GetHttpCode(), errs.GetMessage(), nil).Render(ctx) |
There was a problem hiding this comment.
HTTP 201 more appropriate for resource creation
CreateNewConversation returns 200; 201 Created is the conventional status for successful resource creation.
| return response.New(errs.GetHttpCode(), errs.GetMessage(), nil).Render(ctx) | |
| return response.New(http.StatusCreated, "Successfully created new conversation", res).Render(ctx) |
| implementation4 "github.com/miqdadyyy/gorago/internal/usecase/space/implementation" | ||
| implementation6 "github.com/miqdadyyy/gorago/internal/usecase/space/implementation" | ||
| ) | ||
|
|
| iPubSub := provider.ProvidePubSub(config) | ||
| iExampleUsecase := implementation2.NewExampleUsecase(exampleRepositoryInterface, iPubSub) | ||
| db := provider.ProvideDatabase(config) | ||
| iEmbedder := provider.ProvideEmbedder(config) |
| iExampleUsecase := implementation2.NewExampleUsecase(exampleRepositoryInterface, iPubSub) | ||
| db := provider.ProvideDatabase(config) | ||
| iEmbedder := provider.ProvideEmbedder(config) | ||
| illm := provider.ProvideLLM(config) |
There was a problem hiding this comment.
iStorage declared before db but depends on config only – ordering fine, but readability concern
Provider ordering changed: iStorage now appears before db. Functionally fine (both depend only on config), but the previous grouping (DB → cache → encryption → base repo) was clearer. Ensure the wire injector graph is intentional.
| iConversationMessageRepository := implementation3.NewConversationMessageRepository(baseRepository) | ||
| iSpaceDocumentRepository := implementation4.NewSpaceDocumentRepository(baseRepository) | ||
| iSpaceDocumentChunkRepository := implementation4.NewSpaceDocumentChunk(baseRepository) | ||
| iConversationUsecase := implementation5.NewConversationUsecase(iEmbedder, illm, iStorage, iConversationRepository, iConversationMessageRepository, iSpaceDocumentRepository, iSpaceDocumentChunkRepository) |
There was a problem hiding this comment.
iSpaceDocumentChunkRepository not used in iSpaceUsecase
iSpaceDocumentChunkRepository is created and passed only to iConversationUsecase. If SpaceUsecase also needs chunk access in the future, this wiring may need updating. Verify the conversation usecase truly owns chunk queries and not the space usecase.
| SpaceID int64 `json:"space_id"` | ||
| } | ||
|
|
||
| DocumentStatusEvent struct { |
| github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= | ||
| github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= | ||
| github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= | ||
| github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= |
| github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||
| github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= | ||
| github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= | ||
| github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= |
There was a problem hiding this comment.
| for (const line of part.split('\n')) { | ||
| if (line.startsWith('event: ')) { | ||
| eventType = line.slice(7).trim(); | ||
| } else if (line.startsWith('data: ')) { |
There was a problem hiding this comment.
Multi-line data: fields overwritten instead of concatenated
Per SSE spec, an event can have multiple data: lines that should be joined with \n. Current code overwrites eventData on each data: line → only last line kept.
| } else if (line.startsWith('data: ')) { | |
| } else if (line.startsWith('data: ')) { | |
| eventData += (eventData ? '\n' : '') + line.slice(6); | |
| } |
| // Split on double newline to get complete events | ||
| const parts = buffer.split('\n\n'); | ||
| // Last part may be incomplete — keep it in the buffer | ||
| buffer = parts.pop() ?? ''; |
| eventData = line.slice(6); | ||
| } | ||
| } | ||
|
|
|
|
||
| Always be concise.` | ||
|
|
||
| PromptBuilderMaxContextCharacters = 12000 |
There was a problem hiding this comment.
Document magic number rationale
PromptBuilderMaxContextCharacters = 12000 lacks explanation. Consider adding a comment explaining why 12000 was chosen (e.g., token budget estimation, model context window constraints).
| PromptBuilderMaxContextCharacters = 12000 | |
| // PromptBuilderMaxContextCharacters is the maximum number of characters allowed in the context. | |
| // Approximates ~3000 tokens to leave room for system prompt, history, and response within model limits. | |
| PromptBuilderMaxContextCharacters = 12000 |
| Message conversationucm.ConversationMessageItem `json:"message"` | ||
| } | ||
| ``` | ||
|
|
There was a problem hiding this comment.
Panic recovery sends to closed channel
In the goroutine, defer close(ch) is declared before defer func() { recover... ch <- ... }(). Defers execute LIFO, so recovery runs first (good), but if the panic happens after the channel is already being read and the consumer has exited, sending to ch after close will panic again. More critically, if the panic occurs during the final ch <- send itself, the recovery will try to send on a potentially full/closed channel. Reorder defers or use a select with default when sending in recovery.
| func (u *ConversationUsecase) SendMessage( | ||
| ctx context.Context, | ||
| request entity.SendMessageRequest, | ||
| ) (<-chan entity.SSEEvent, errors.IError) { |
There was a problem hiding this comment.
| req, err := http.NewRequestWithContext( | ||
| ctx, | ||
| http.MethodPost, | ||
| o.cfg.BaseURL+"/api/generate", |
There was a problem hiding this comment.
streamHTTPClient has no timeout — potential goroutine/connection leak
If the context is never cancelled (e.g., caller forgets), the HTTP connection and goroutine will hang indefinitely. Consider adding a generous but finite timeout (e.g., 10 minutes) as a safety net, or document that callers MUST cancel the context.
| spaceDocumentChunks, err := u.spaceDocumentChunkRepository.GetSpaceDocumentChunkByVector( | ||
| ctx, | ||
| spacerpm.GetSpaceDocumentByVectorPayload{ | ||
| Vector: vectorQuery, |
There was a problem hiding this comment.
Handler creates context.Background() — no request-scoped deadline
Using context.Background() means there's no upper bound on how long the streaming goroutine can run. If the client disconnects and Fiber doesn't signal the writer, the usecase goroutine runs forever. Consider deriving from a timeout context or detecting writer errors to call cancel().
| Type: entity.SSEEventTypeStatus, | ||
| Data: map[string]string{"phase": "generating"}, | ||
| } | ||
|
|
There was a problem hiding this comment.
Writer error detection doesn't cancel upstream context
When w.Flush() returns an error (client disconnected), the handler returns but cancel() is deferred so it will fire. This is correct. However, the usecase goroutine may still be blocked sending to eventCh if the buffer is full. Since the handler stops reading, the channel send in the usecase will block until GC. Consider draining the channel or using a select-based send in the usecase.
| } | ||
|
|
||
| ch := make(chan llm.GenerateStreamChunk, 32) | ||
|
|
There was a problem hiding this comment.
Scanner default buffer may be too small for large JSON chunks
bufio.Scanner has a default max token size of 64KB. If Ollama ever returns a line larger than that (unlikely but possible with large prompt_eval metadata), the scanner will fail silently. Consider scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) for safety.
| } | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
| } | ||
| }() | ||
|
|
||
| return ch, nil |
There was a problem hiding this comment.
| ConversationMessageItem struct { | ||
| Role string `json:"role"` | ||
| Content string `json:"content"` | ||
| Metadata any `json:"metadata"` |
There was a problem hiding this comment.
| Role string `json:"role"` | ||
| Content string `json:"content"` | ||
| Metadata any `json:"metadata"` | ||
| CreatedAt *time.Time `json:"created_at"` |
There was a problem hiding this comment.
Pointer time fields – consider omitempty
Pointer *time.Time will serialize as null when nil. Add omitempty if null timestamps should be omitted from JSON output.
| CreatedAt *time.Time `json:"created_at"` | |
| CreatedAt *time.Time `json:"created_at,omitempty"` | |
| UpdatedAt *time.Time `json:"updated_at,omitempty"` |
| **Update `SendMessage` handler** (`internal/handler/http/conversation.go`): | ||
|
|
||
| - Parse and validate request (conversation ID, body) | ||
| - Call `conversationUsecase.SendMessage()` — if error returned, respond with normal JSON error (400/404) |
There was a problem hiding this comment.
json.Marshal error silently discarded
The handler code sample discards the error from json.Marshal(event.Data). If marshaling fails (e.g., a channel or func value in Data), the stream will emit malformed/empty JSON. Handle the error or at minimum log it and send an error event.
| - Call `conversationUsecase.SendMessage()` — if error returned, respond with normal JSON error (400/404) | |
| data, err := json.Marshal(event.Data) | |
| if err != nil { | |
| fmt.Fprintf(w, "event: error\ndata: {\"message\":\"internal marshal error\"}\n\n") | |
| w.Flush() | |
| cancel() | |
| return | |
| } | |
| fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data) |
| 12. Save assistant message to DB (with accumulated content + metadata) | ||
| 13. **Push `done` with the full saved message** | ||
| 14. Close channel (via defer) | ||
|
|
There was a problem hiding this comment.
context.Background() loses request-scoped values
Using context.WithCancel(context.Background()) discards tracing spans, auth info, and any request-scoped values. Consider deriving from a detached copy of the request context that preserves values but not the cancellation, or explicitly propagate needed values.
| 5. Embed query via embedder | ||
| 6. Vector search for relevant chunks | ||
| 7. Build RAG prompt | ||
| 8. **Push `status: generating`** |
There was a problem hiding this comment.
| SSEEventTypeError SSEEventType = "error" | ||
| ) | ||
|
|
||
| type SSEEvent struct { |
There was a problem hiding this comment.
| } | ||
| ``` | ||
|
|
||
| Returns a **channel and an error**. The error return handles pre-stream validation failures (conversation not found, invalid request) so the handler can return proper HTTP 400/404 status codes. If `err` is nil, the channel is valid and streaming begins. The channel uses a **buffer size of 32**. |
There was a problem hiding this comment.
Partial content not saved but user message is — inconsistent state
Step 2 saves the user message before streaming begins. If the LLM fails mid-stream, the user message persists but no assistant message is saved (per error handling section). This leaves the conversation in an inconsistent state with a dangling user message and no response. Document the expected recovery path (retry? delete user msg?).
| ### 4. HTTP Handler | ||
|
|
||
| **Update `SendMessage` handler** (`internal/handler/http/conversation.go`): | ||
|
|
There was a problem hiding this comment.
fmt.Fprintf return error unchecked
fmt.Fprintf(w, ...) can also return an error (e.g., broken pipe). Only w.Flush() is checked. If Fprintf fails, the subsequent Flush may succeed on buffered data, delaying disconnect detection. Consider checking Fprintf's error as well.
| if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data); err != nil { | |
| cancel() | |
| return | |
| } | |
| if err := w.Flush(); err != nil { | |
| cancel() | |
| return | |
| } |
|
|
||
| type ILLM interface { | ||
| Generate(ctx context.Context, payload GenerateRequest) (response GenerateResponse, err error) | ||
| GenerateStream(ctx context.Context, payload GenerateRequest) (<-chan GenerateStreamChunk, error) |
There was a problem hiding this comment.
No mid-stream error propagation mechanism
Returning only <-chan GenerateStreamChunk means errors occurring during streaming (after the channel is created) can only be communicated via a field inside GenerateStreamChunk. Ensure GenerateStreamChunk contains an Err error field (or similar) so consumers can detect partial failures without a separate side-channel.
| logger := log.WithContext(ctx) | ||
| outputPath = fmt.Sprintf("%s/", getDocumentDirectory(path)) | ||
| if err = api.ExtractPagesFile(path, outputPath, nil, nil); err != nil { | ||
| if err = os.MkdirAll(outputPath, os.ModePerm); err != nil { |
There was a problem hiding this comment.
| return "", err | ||
| } | ||
|
|
||
| if err = api.ExtractContentFile(path, outputPath, nil, nil); err != nil { |
There was a problem hiding this comment.
API changed from ExtractPagesFile to ExtractContentFile
ExtractContentFile extracts raw content streams (not rendered page PDFs). If the intent is to extract individual page PDF files, this should remain ExtractPagesFile. If the change is intentional, the error message and function name are misleading.
| if err = api.ExtractContentFile(path, outputPath, nil, nil); err != nil { | |
| if err = api.ExtractPagesFile(path, outputPath, nil, nil); err != nil { |
|
|
||
| func ProvidePDF() pdf.IPDF { | ||
| return pdf.NewPDFCpu() | ||
| return pdf.NewPDFPoppler() |
There was a problem hiding this comment.
External binary dependency not validated
PDFPoppler typically shells out to poppler-utils (pdftotext, pdftoppm). If these aren't present in the deployment environment the call will fail at runtime. Consider adding a startup check (e.g., exec.LookPath) or documenting the new dependency in Dockerfile/CI.
| if err != nil { | ||
| panic("failed to connect to database") | ||
| } | ||
| //_ = db.Migrator().DropTable( |
| // &spacerpm.SpaceDocumentChunk{}, | ||
| //) | ||
|
|
||
| _ = db.AutoMigrate( |
There was a problem hiding this comment.
AutoMigrate error ignored
Ignoring AutoMigrate error (_ =) can hide schema issues silently → hard-to-debug runtime failures. Handle or at least log.
| _ = db.AutoMigrate( | |
| if err := db.AutoMigrate( | |
| &conversationrpm.Conversation{}, | |
| &conversationrpm.ConversationMessage{}, | |
| &conversationrpm.ConversationMessageContext{}, | |
| &spacerpm.Space{}, | |
| &spacerpm.SpaceDocument{}, | |
| &spacerpm.SpaceDocumentIngestion{}, | |
| &spacerpm.SpaceDocumentChunk{}, | |
| ); err != nil { | |
| panic("failed to auto-migrate: " + err.Error()) | |
| } |
|
|
||
| func ProvideLLM(cfg config.Config) llm.ILLM { | ||
| return ollama.NewOllamaLLM(cfg.LLM.Ollama.BaseURL) | ||
| return ollama.NewOllamaLLM(cfg.LLM.Ollama) |
No description provided.