Skip to content

Commit 3d37b4a

Browse files
committed
Implement AI configuration management and enhance document handling
- Introduced new API endpoints for managing AI configuration, allowing retrieval and secure storage of API keys. - Updated the configuration structure to support encryption of AI keys, enhancing security. - Refactored document listing to include throttled logging for processing counts, improving performance monitoring. - Enhanced the UI components to support multiple file uploads and provide user feedback during uploads. - Improved the onboarding experience with contextual messages for new users. These changes aim to streamline AI integration and improve user experience in document management and configuration settings.
1 parent c61285d commit 3d37b4a

37 files changed

+1920
-390
lines changed

demo/docker-compose-prod.yml

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# Run from demo/: docker compose -f docker-compose-prod.yml up -d
44
# Web: http://localhost:3000 API: http://localhost:8080
55
#
6-
# AI credentials: copy demo/.env.example to demo/.env and fill in.
6+
# AI config: set in the app via Settings (stored encrypted in DB). For CLI-only use, set
7+
# OPENAI_* / AZURE_* in .env; do not pass them to API/worker here (server uses DB).
8+
# Set DOCPROC_ENCRYPTION_KEY in .env (32 bytes or passphrase) so the API can encrypt stored keys.
79
name: docproc-edu
810

911
services:
@@ -66,12 +68,7 @@ services:
6668
S3_BUCKET: docproc-demo
6769
AWS_REGION: us-east-1
6870
MQ_URL: amqp://docproc:docproc@rabbitmq:5672/
69-
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
70-
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini}
71-
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
72-
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-}
73-
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-}
74-
AZURE_OPENAI_EMBEDDING_DEPLOYMENT: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:-}
71+
DOCPROC_ENCRYPTION_KEY: ${DOCPROC_ENCRYPTION_KEY:-}
7572
depends_on:
7673
postgres: { condition: service_healthy }
7774
rabbitmq: { condition: service_healthy }
@@ -90,15 +87,6 @@ services:
9087
S3_BUCKET: docproc-demo
9188
AWS_REGION: us-east-1
9289
MQ_URL: amqp://docproc:docproc@rabbitmq:5672/
93-
DOCPROC_PRIMARY_AI: ${DOCPROC_PRIMARY_AI:-azure}
94-
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
95-
OPENAI_MODEL: ${OPENAI_MODEL:-}
96-
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
97-
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-}
98-
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-gpt-4o}
99-
AZURE_OPENAI_EMBEDDING_DEPLOYMENT: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:-text-embedding-ada-002}
100-
AZURE_VISION_ENDPOINT: ${AZURE_VISION_ENDPOINT:-}
101-
AZURE_VISION_KEY: ${AZURE_VISION_KEY:-}
10290
depends_on:
10391
postgres: { condition: service_healthy }
10492
rabbitmq: { condition: service_healthy }

demo/go/internal/api/documents.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ import (
77
"net/http"
88
"path"
99
"strings"
10+
"sync"
11+
"time"
1012

1113
"github.com/google/uuid"
1214
"github.com/rithulkamesh/docproc/demo/internal/blob"
1315
"github.com/rithulkamesh/docproc/demo/internal/db"
1416
"github.com/rithulkamesh/docproc/demo/internal/mq"
1517
)
1618

19+
var (
20+
listLogMu sync.Mutex
21+
listLogState = map[string]struct{ lastLogUnix int64; lastCount int }{}
22+
)
23+
1724
// Document routes: upload, list, get, delete, reindex
1825
func (h *Handler) documents(w http.ResponseWriter, r *http.Request) {
1926
path := strings.TrimPrefix(r.URL.Path, "/documents")
@@ -143,7 +150,18 @@ func (h *Handler) listDocuments(w http.ResponseWriter, r *http.Request) {
143150
}
144151
}
145152
if processingCount > 0 {
146-
log.Printf("[documents] list: project_id=%s total=%d processing=%d", projectID, len(list), processingCount)
153+
now := time.Now().UnixNano()
154+
throttleNanos := int64(30 * time.Second)
155+
listLogMu.Lock()
156+
state, ok := listLogState[projectID]
157+
shouldLog := !ok || state.lastCount != processingCount || (now-state.lastLogUnix) > throttleNanos
158+
if shouldLog {
159+
listLogState[projectID] = struct{ lastLogUnix int64; lastCount int }{lastLogUnix: now, lastCount: processingCount}
160+
}
161+
listLogMu.Unlock()
162+
if shouldLog {
163+
log.Printf("[documents] list: project_id=%s total=%d processing=%d", projectID, len(list), processingCount)
164+
}
147165
}
148166
docs := make([]any, len(list))
149167
for i, d := range list {

demo/go/internal/api/handler.go

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4949
h.queryStream(w, r)
5050
case path == "/models" && r.Method == http.MethodGet:
5151
h.models(w, r)
52+
case path == "/ai-config" && r.Method == http.MethodGet:
53+
h.getAIConfig(w, r)
54+
case path == "/ai-config" && r.Method == http.MethodPut:
55+
h.putAIConfig(w, r)
5256
case path == "/documents" || path == "/documents/" || strings.HasPrefix(path, "/documents/"):
5357
h.documents(w, r)
5458
case path == "/projects" || path == "/projects/" || strings.HasPrefix(path, "/projects/"):
@@ -67,22 +71,107 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6771
}
6872

6973
func (h *Handler) status(w http.ResponseWriter, r *http.Request) {
74+
// Expose only non-secret server AI config (from .env). API keys are never sent to the client.
75+
embedDep := h.cfg.DefaultEmbeddingDeployment()
76+
var embedDepVal any = nil
77+
if embedDep != "" {
78+
embedDepVal = embedDep
79+
}
7080
writeJSON(w, map[string]any{
71-
"ok": true,
72-
"rag_backend": "embedding",
73-
"rag_configured": h.rag != nil,
74-
"database_provider": "pgvector",
81+
"ok": true,
82+
"rag_backend": "embedding",
83+
"rag_configured": h.rag != nil,
84+
"database_provider": "pgvector",
7585
"primary_ai": h.cfg.PrimaryAI(),
76-
"namespace": "default",
77-
"default_rag_model": h.cfg.DefaultRAGModel(),
78-
"embedding_deployment": nil,
86+
"namespace": "default",
87+
"default_rag_model": h.cfg.DefaultRAGModel(),
88+
"embedding_deployment": embedDepVal,
7989
})
8090
}
8191

8292
func (h *Handler) embedCheck(w http.ResponseWriter, r *http.Request) {
8393
writeJSON(w, map[string]any{"ok": h.cfg.HasAI()})
8494
}
8595

96+
func (h *Handler) getAIConfig(w http.ResponseWriter, r *http.Request) {
97+
ctx := r.Context()
98+
cfg, err := h.pool.GetAIConfig(ctx)
99+
if err != nil {
100+
writeError(w, err.Error(), http.StatusInternalServerError)
101+
return
102+
}
103+
if cfg == nil {
104+
writeJSON(w, map[string]any{
105+
"provider": "openai",
106+
"model": "gpt-4o-mini",
107+
"api_key_configured": false,
108+
"base_url": "",
109+
"endpoint": "",
110+
"deployment": "",
111+
"embedding_deployment": "",
112+
})
113+
return
114+
}
115+
writeJSON(w, map[string]any{
116+
"provider": cfg.Provider,
117+
"model": cfg.Model,
118+
"api_key_configured": cfg.APIKeyConfigured,
119+
"base_url": cfg.BaseURL,
120+
"endpoint": cfg.Endpoint,
121+
"deployment": cfg.Deployment,
122+
"embedding_deployment": cfg.EmbeddingDeployment,
123+
})
124+
}
125+
126+
func (h *Handler) putAIConfig(w http.ResponseWriter, r *http.Request) {
127+
if len(h.cfg.EncryptionKey) != 32 {
128+
writeError(w, "DOCPROC_ENCRYPTION_KEY must be set (32 bytes or passphrase) to store API keys securely", http.StatusBadRequest)
129+
return
130+
}
131+
var in db.AIConfigSaveInput
132+
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
133+
writeError(w, "invalid JSON", http.StatusBadRequest)
134+
return
135+
}
136+
if in.Provider == "" {
137+
in.Provider = "openai"
138+
}
139+
if in.Model == "" {
140+
in.Model = "gpt-4o-mini"
141+
}
142+
if err := h.pool.SaveAIConfig(r.Context(), h.cfg.EncryptionKey, &in); err != nil {
143+
writeError(w, err.Error(), http.StatusInternalServerError)
144+
return
145+
}
146+
h.getAIConfig(w, r)
147+
}
148+
149+
// openaiClientFromDBConfig builds an OpenAI client from DB config (for query/stream when no key in body).
150+
func openaiClientFromDBConfig(cfg *db.AIConfigDecrypted) (*openai.Client, string) {
151+
if cfg == nil || cfg.APIKey == "" {
152+
return nil, ""
153+
}
154+
model := cfg.Model
155+
if model == "" {
156+
model = "gpt-4o-mini"
157+
}
158+
switch cfg.Provider {
159+
case "azure":
160+
endpoint := cfg.Endpoint
161+
if endpoint == "" {
162+
return nil, ""
163+
}
164+
clientConfig := openai.DefaultAzureConfig(cfg.APIKey, endpoint)
165+
return openai.NewClientWithConfig(clientConfig), model
166+
default:
167+
clientConfig := openai.DefaultConfig(cfg.APIKey)
168+
if cfg.BaseURL != "" {
169+
clientConfig.BaseURL = cfg.BaseURL
170+
}
171+
return openai.NewClientWithConfig(clientConfig), model
172+
}
173+
}
174+
86175
func (h *Handler) query(w http.ResponseWriter, r *http.Request) {
87176
var body struct {
88177
Query string `json:"query"`
@@ -103,15 +192,23 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) {
103192
writeError(w, "missing query or prompt", http.StatusBadRequest)
104193
return
105194
}
106-
// RAG is required for embeddings and retrieval; api_key/model in body override chat only
195+
// RAG is required for embeddings and retrieval; api_key/model in body override; else use DB-stored config.
107196
if h.rag == nil {
108-
writeJSON(w, map[string]any{"answer": "RAG not configured. Set OPENAI_API_KEY or AZURE_OPENAI_* in .env.", "sources": []any{}})
197+
writeJSON(w, map[string]any{"answer": "RAG not configured. Configure AI in Settings or set OPENAI_API_KEY / AZURE_OPENAI_* in .env.", "sources": []any{}})
109198
return
110199
}
111200
var chatClient *openai.Client
112201
model := strings.TrimSpace(body.Model)
113202
if body.APIKey != "" {
114203
chatClient = openai.NewClient(strings.TrimSpace(body.APIKey))
204+
} else if len(h.cfg.EncryptionKey) == 32 {
205+
dbCfg, _ := h.pool.GetAIConfigDecrypted(r.Context(), h.cfg.EncryptionKey)
206+
if dbCfg != nil && dbCfg.APIKey != "" {
207+
chatClient, model = openaiClientFromDBConfig(dbCfg)
208+
if model == "" {
209+
model = dbCfg.Model
210+
}
211+
}
115212
}
116213
answer, sources, err := h.rag.QueryWithClient(r.Context(), q, chatClient, model)
117214
if err != nil {
@@ -189,6 +286,14 @@ func (h *Handler) queryStream(w http.ResponseWriter, r *http.Request) {
189286
model := strings.TrimSpace(body.Model)
190287
if body.APIKey != "" {
191288
streamClient = openai.NewClient(strings.TrimSpace(body.APIKey))
289+
} else if len(h.cfg.EncryptionKey) == 32 {
290+
dbCfg, _ := h.pool.GetAIConfigDecrypted(ctx, h.cfg.EncryptionKey)
291+
if dbCfg != nil && dbCfg.APIKey != "" {
292+
streamClient, model = openaiClientFromDBConfig(dbCfg)
293+
if model == "" {
294+
model = dbCfg.Model
295+
}
296+
}
192297
}
193298
if err := h.rag.StreamCompletionWithClient(ctx, prompt, w, streamClient, model); err != nil {
194299
return

demo/go/internal/config/config.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"os"
55

6+
"github.com/rithulkamesh/docproc/demo/internal/secure"
67
"github.com/sashabaranov/go-openai"
78
)
89

@@ -14,16 +15,19 @@ type Config struct {
1415
S3Region string
1516
MQURL string // RabbitMQ AMQP URL
1617
DocprocCLI string // Path to docproc binary (default: docproc)
17-
OpenAIKey string // For embeddings + LLM (RAG)
18-
OpenAIModel string
18+
// EncryptionKey is 32 bytes for encrypting/decrypting AI API keys in DB. From DOCPROC_ENCRYPTION_KEY.
19+
EncryptionKey []byte
20+
OpenAIKey string // For embeddings + LLM (RAG) — fallback when no DB config
21+
OpenAIModel string
1922
// Azure OpenAI (used when OPENAI_API_KEY is not set)
20-
AzureAPIKey string
21-
AzureEndpoint string
22-
AzureDeployment string
23-
AzureEmbeddingDeployment string
23+
AzureAPIKey string
24+
AzureEndpoint string
25+
AzureDeployment string
26+
AzureEmbeddingDeployment string
2427
}
2528

2629
// Load reads config from environment. Uses OPENAI_API_KEY if set; otherwise AZURE_OPENAI_*.
30+
// DOCPROC_ENCRYPTION_KEY is used to encrypt AI keys stored in the DB (32 bytes or passphrase).
2731
func Load() (*Config, error) {
2832
c := &Config{
2933
DatabaseURL: getEnv("DATABASE_URL", "postgresql://docproc:docproc@localhost:5432/docproc?sslmode=disable"),
@@ -32,6 +36,7 @@ func Load() (*Config, error) {
3236
S3Region: getEnv("AWS_REGION", "us-east-1"),
3337
MQURL: getEnv("MQ_URL", "amqp://docproc:docproc@localhost:5672/"),
3438
DocprocCLI: getEnv("DOCPROC_CLI", "docproc"),
39+
EncryptionKey: keyFromEnvOrNil(os.Getenv("DOCPROC_ENCRYPTION_KEY")),
3540
OpenAIKey: os.Getenv("OPENAI_API_KEY"),
3641
OpenAIModel: getEnv("OPENAI_MODEL", "gpt-4o-mini"),
3742
AzureAPIKey: os.Getenv("AZURE_OPENAI_API_KEY"),
@@ -69,6 +74,15 @@ func (c *Config) DefaultRAGModel() string {
6974
return c.OpenAIModel
7075
}
7176

77+
// DefaultEmbeddingDeployment returns the Azure embedding deployment name when Azure is primary, else empty.
78+
// Used only for /status so the frontend can show server defaults; never exposes keys or endpoints.
79+
func (c *Config) DefaultEmbeddingDeployment() string {
80+
if c.PrimaryAI() == "azure" && c.AzureEmbeddingDeployment != "" {
81+
return c.AzureEmbeddingDeployment
82+
}
83+
return ""
84+
}
85+
7286
// AIClient returns an OpenAI-compatible client and model names (chat, embedding) using the default provider:
7387
// OPENAI_API_KEY if set, else AZURE_OPENAI_* if set. Returns (nil, "", "") when neither is configured.
7488
func (c *Config) AIClient() (client *openai.Client, chatModel, embeddingModel string) {
@@ -88,3 +102,11 @@ func getEnv(key, defaultVal string) string {
88102
}
89103
return defaultVal
90104
}
105+
106+
// keyFromEnvOrNil returns a 32-byte key derived from passphrase, or nil if passphrase is empty.
107+
func keyFromEnvOrNil(passphrase string) []byte {
108+
if passphrase == "" {
109+
return nil
110+
}
111+
return secure.KeyFromEnv(passphrase)
112+
}

0 commit comments

Comments
 (0)