|
- package mapping
-
- import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
-
- "qctextbuilder/internal/domain"
- "qctextbuilder/internal/llmruntime"
- )
-
- type SettingsReader interface {
- GetSettings(ctx context.Context) (*domain.AppSettings, error)
- }
-
- type ProviderAwareSuggestionGenerator struct {
- settings SettingsReader
- runtimeFactory *llmruntime.Factory
- }
-
- func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator {
- return &ProviderAwareSuggestionGenerator{
- settings: settings,
- runtimeFactory: runtimeFactory,
- }
- }
-
- func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
- if g == nil || g.settings == nil || g.runtimeFactory == nil {
- return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured")
- }
-
- settings, err := g.settings.GetSettings(ctx)
- if err != nil || settings == nil {
- return SuggestionResult{}, fmt.Errorf("llm settings are not available")
- }
- provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
- model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
- if strings.TrimSpace(model) == "" {
- return SuggestionResult{}, fmt.Errorf("no active model configured")
- }
- apiKey := apiKeyForProvider(provider, *settings)
- if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
- return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured", provider)
- }
-
- targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
- if len(targets) == 0 {
- return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil
- }
- allowed := make(map[string]SemanticSlotTarget, len(targets))
- for _, target := range targets {
- allowed[target.FieldPath] = target
- }
-
- providerClient, err := g.runtimeFactory.ClientFor(provider)
- if err != nil {
- return SuggestionResult{}, err
- }
- systemPrompt, userPrompt := buildProviderPrompts(req, targets)
- raw, err := providerClient.Generate(ctx, llmruntime.Request{
- Provider: provider,
- Model: model,
- BaseURL: strings.TrimSpace(settings.LLMBaseURL),
- APIKey: strings.TrimSpace(apiKey),
- SystemPrompt: systemPrompt,
- UserPrompt: userPrompt,
- })
- if err != nil {
- return SuggestionResult{}, err
- }
-
- parsed, err := parseProviderSuggestions(raw)
- if err != nil {
- return SuggestionResult{}, err
- }
-
- out := SuggestionResult{
- Suggestions: make([]Suggestion, 0, len(parsed)),
- ByFieldPath: map[string]Suggestion{},
- }
- for _, item := range parsed {
- fieldPath := strings.TrimSpace(item.FieldPath)
- target, ok := allowed[fieldPath]
- if !ok {
- continue
- }
- value := strings.TrimSpace(item.Value)
- if value == "" {
- continue
- }
- suggestion := Suggestion{
- FieldPath: fieldPath,
- Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot),
- Value: value,
- Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"),
- Source: provider,
- }
- if _, exists := out.ByFieldPath[fieldPath]; exists {
- continue
- }
- out.Suggestions = append(out.Suggestions, suggestion)
- out.ByFieldPath[fieldPath] = suggestion
- }
- return out, nil
- }
-
- type providerSuggestion struct {
- FieldPath string `json:"fieldPath"`
- Slot string `json:"slot,omitempty"`
- Value string `json:"value"`
- Reason string `json:"reason,omitempty"`
- }
-
- func parseProviderSuggestions(raw string) ([]providerSuggestion, error) {
- content := strings.TrimSpace(raw)
- if content == "" {
- return nil, fmt.Errorf("empty provider response")
- }
-
- candidates := []string{content}
- if fence := extractFencedJSON(content); fence != "" {
- candidates = append([]string{fence}, candidates...)
- }
- if object := extractJSONObject(content); object != "" {
- candidates = append(candidates, object)
- }
-
- for _, candidate := range candidates {
- items, ok := parseSuggestionsCandidate(candidate)
- if ok {
- return items, nil
- }
- }
- return nil, fmt.Errorf("provider response is not valid suggestions json")
- }
-
- func parseSuggestionsCandidate(raw string) ([]providerSuggestion, bool) {
- var objectPayload struct {
- Suggestions []providerSuggestion `json:"suggestions"`
- }
- if err := json.Unmarshal([]byte(raw), &objectPayload); err == nil && len(objectPayload.Suggestions) > 0 {
- return objectPayload.Suggestions, true
- }
- var listPayload []providerSuggestion
- if err := json.Unmarshal([]byte(raw), &listPayload); err == nil && len(listPayload) > 0 {
- return listPayload, true
- }
- return nil, false
- }
-
- func extractFencedJSON(value string) string {
- const fence = "```"
- start := strings.Index(value, fence)
- for start >= 0 {
- rest := value[start+len(fence):]
- end := strings.Index(rest, fence)
- if end < 0 {
- return ""
- }
- block := strings.TrimSpace(rest[:end])
- block = strings.TrimPrefix(block, "json")
- block = strings.TrimPrefix(block, "JSON")
- block = strings.TrimSpace(block)
- if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") {
- return block
- }
- nextOffset := start + len(fence) + end + len(fence)
- nextStart := strings.Index(value[nextOffset:], fence)
- if nextStart < 0 {
- break
- }
- start = nextOffset + nextStart
- }
- return ""
- }
-
- func extractJSONObject(value string) string {
- start := strings.IndexAny(value, "{[")
- if start < 0 {
- return ""
- }
- end := strings.LastIndexAny(value, "}]")
- if end <= start {
- return ""
- }
- return strings.TrimSpace(value[start : end+1])
- }
-
- func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) {
- targetPayload := make([]map[string]string, 0, len(targets))
- for _, target := range targets {
- targetPayload = append(targetPayload, map[string]string{
- "fieldPath": strings.TrimSpace(target.FieldPath),
- "slot": strings.TrimSpace(target.Slot),
- })
- }
- contextPayload := map[string]any{
- "globalData": req.GlobalData,
- "draftContext": llmDraftContextMap(req.DraftContext),
- "masterPrompt": strings.TrimSpace(req.MasterPrompt),
- "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
- "targets": targetPayload,
- }
- contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
-
- system := "You generate website text suggestions. Return JSON only. Format: {\"suggestions\":[{\"fieldPath\":\"...\",\"slot\":\"...\",\"value\":\"...\",\"reason\":\"...\"}]}. Use only provided field paths. Keep values concise and in input language."
- user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON)
- return system, user
- }
-
- func apiKeyForProvider(provider string, settings domain.AppSettings) string {
- switch provider {
- case domain.LLMProviderOpenAI:
- return strings.TrimSpace(settings.OpenAIAPIKeyEncrypted)
- case domain.LLMProviderAnthropic:
- return strings.TrimSpace(settings.AnthropicAPIKeyEncrypted)
- case domain.LLMProviderGoogle:
- return strings.TrimSpace(settings.GoogleAPIKeyEncrypted)
- case domain.LLMProviderXAI:
- return strings.TrimSpace(settings.XAIAPIKeyEncrypted)
- case domain.LLMProviderOllama:
- return strings.TrimSpace(settings.OllamaAPIKeyEncrypted)
- default:
- return ""
- }
- }
|