|
- package app
-
- import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
-
- "github.com/go-chi/chi/v5"
-
- "qctextbuilder/internal/buildsvc"
- "qctextbuilder/internal/config"
- "qctextbuilder/internal/domain"
- "qctextbuilder/internal/draftsvc"
- "qctextbuilder/internal/httpserver"
- "qctextbuilder/internal/httpserver/handlers"
- "qctextbuilder/internal/httpserver/views"
- "qctextbuilder/internal/llmruntime"
- "qctextbuilder/internal/logging"
- "qctextbuilder/internal/mapping"
- "qctextbuilder/internal/onboarding"
- "qctextbuilder/internal/polling"
- "qctextbuilder/internal/qcclient"
- "qctextbuilder/internal/store"
- "qctextbuilder/internal/store/memory"
- "qctextbuilder/internal/store/sqlite"
- "qctextbuilder/internal/templatesvc"
- )
-
- type App struct {
- server *httpserver.Server
- pollingSvc *polling.Service
- }
-
- func New(cfg config.Config) (*App, error) {
- logger := logging.New()
-
- var (
- templateStore store.TemplateStore
- manifestStore store.ManifestStore
- buildStore store.BuildStore
- draftStore store.DraftStore
- settingsStore store.SettingsStore
- )
-
- driver := strings.ToLower(strings.TrimSpace(cfg.DBDriver))
- switch driver {
- case "", "sqlite":
- sqliteStore, err := sqlite.New(cfg.DBURL)
- if err != nil {
- return nil, fmt.Errorf("init sqlite store: %w", err)
- }
- templateStore = sqliteStore
- manifestStore = sqliteStore
- buildStore = sqliteStore
- draftStore = sqliteStore
- settingsStore = sqliteStore
- default:
- memStore := memory.New()
- templateStore = memStore
- manifestStore = memStore
- buildStore = memStore
- draftStore = memStore
- settingsStore = memStore
- }
-
- qc := qcclient.New(cfg.QCBaseURL, cfg.QCToken, 15*time.Second, logger)
- templateSvc := templatesvc.New(qc, templateStore, manifestStore)
- onboardSvc := onboarding.New(qc, templateStore, manifestStore)
- draftSvc := draftsvc.New(draftStore, templateStore, manifestStore)
- mappingSvc := mapping.New()
- buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second)
- providerRuntime := llmruntime.NewFactory(45 * time.Second)
- suggestionGenerator := mapping.NewCompositeSuggestionGenerator(
- mapping.NewProviderAwareSuggestionGenerator(settingsStore, providerRuntime),
- mapping.NewCompositeSuggestionGenerator(
- mapping.NewQCLLMSuggestionGenerator(qc),
- mapping.NewRuleBasedSuggestionGenerator(),
- ),
- )
- pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger)
- api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc)
-
- baseSettings := domain.AppSettings{
- QCBaseURL: cfg.QCBaseURL,
- QCBearerTokenEncrypted: cfg.QCToken,
- LanguageOutputMode: "EN",
- JobPollIntervalSeconds: cfg.PollIntervalSeconds,
- JobPollTimeoutSeconds: cfg.PollTimeoutSeconds,
- LLMActiveProvider: domain.DefaultLLMProvider(),
- LLMActiveModel: domain.NormalizeLLMModel(domain.DefaultLLMProvider(), ""),
- LLMTemperature: domain.DefaultLLMTemperature(),
- LLMMaxTokens: domain.DefaultLLMMaxTokens(),
- MasterPrompt: domain.SeedMasterPrompt,
- PromptBlocks: domain.DefaultPromptBlocks(),
- }
- if existing, err := settingsStore.GetSettings(context.Background()); err == nil && existing != nil {
- baseSettings.LLMActiveProvider = existing.LLMActiveProvider
- baseSettings.LLMActiveModel = existing.LLMActiveModel
- baseSettings.LLMBaseURL = existing.LLMBaseURL
- baseSettings.LLMTemperature = existing.LLMTemperature
- baseSettings.LLMMaxTokens = existing.LLMMaxTokens
- baseSettings.OpenAIAPIKeyEncrypted = existing.OpenAIAPIKeyEncrypted
- baseSettings.AnthropicAPIKeyEncrypted = existing.AnthropicAPIKeyEncrypted
- baseSettings.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted
- baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted
- baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted
- baseSettings.MasterPrompt = existing.MasterPrompt
- baseSettings.PromptBlocks = existing.PromptBlocks
- }
- _ = settingsStore.UpsertSettings(context.Background(), baseSettings)
-
- renderer, err := views.NewRenderer("web/templates/*.gohtml")
- if err != nil {
- return nil, fmt.Errorf("init renderer: %w", err)
- }
- ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, settingsStore, suggestionGenerator, cfg, renderer)
-
- server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) {
- r.Get("/", ui.Home)
- r.Get("/settings", ui.Settings)
- r.Post("/settings/llm", ui.SaveLLMSettings)
- r.Post("/settings/llm/validate", ui.ValidateLLMSettings)
- r.Post("/settings/prompt", ui.SavePromptSettings)
- r.Get("/templates", ui.Templates)
- r.Post("/templates/sync", ui.SyncTemplates)
- r.Get("/templates/{id}", ui.TemplateDetail)
- r.Post("/templates/{id}/onboard", ui.OnboardTemplate)
- r.Post("/templates/{id}/fields", ui.UpdateTemplateFields)
- r.Get("/builds/new", ui.BuildNew)
- r.Post("/builds/drafts", ui.SaveDraft)
- r.Post("/builds/drafts/autofill", ui.AutofillDraft)
- r.Post("/builds", ui.CreateBuild)
- r.Get("/builds/{id}", ui.BuildDetail)
- r.Post("/builds/{id}/poll", ui.PollBuild)
- r.Post("/builds/{id}/fetch-editor-url", ui.FetchEditorURL)
-
- r.Get("/healthz", api.Health)
- r.Route("/api", func(r chi.Router) {
- r.Post("/templates/sync", api.SyncTemplates)
- r.Get("/templates", api.ListTemplates)
- r.Get("/templates/{id}", api.GetTemplateDetail)
- r.Post("/templates/{id}/onboard", api.OnboardTemplate)
- r.Put("/templates/{id}/fields", api.UpdateTemplateFields)
- r.Post("/drafts/intake", api.IntakeDraft)
- r.Get("/drafts", api.ListDrafts)
- r.Get("/drafts/{id}", api.GetDraft)
- r.Put("/drafts/{id}", api.UpdateDraft)
- r.Post("/site-builds", api.StartBuild)
- r.Get("/site-builds/{id}", api.GetBuild)
- r.Post("/site-builds/{id}/poll", api.PollBuildOnce)
- r.Post("/site-builds/{id}/fetch-editor-url", api.FetchBuildEditorURL)
- })
- })
-
- return &App{server: server, pollingSvc: pollingSvc}, nil
- }
-
- func (a *App) Run(ctx context.Context) error {
- go func() {
- if err := a.pollingSvc.Start(ctx); err != nil {
- // polling is best-effort in milestone-2; request flow works without supervisor
- }
- }()
-
- errCh := make(chan error, 1)
- go func() {
- if err := a.server.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
- errCh <- fmt.Errorf("http run: %w", err)
- }
- close(errCh)
- }()
-
- select {
- case <-ctx.Done():
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- return a.server.Shutdown(shutdownCtx)
- case err := <-errCh:
- return err
- }
- }
|