Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseJSONResponse attempts to extract a JSON object from LLM output.
|
||||
// LLMs often wrap JSON in markdown code blocks (```json ... ```),
|
||||
// so this function strips those wrappers before parsing.
|
||||
// Returns the parsed struct or an error if parsing fails.
|
||||
func parseJSONResponse(content string, target interface{}) error {
|
||||
// Strip markdown code block wrapper if present
|
||||
jsonStr := content
|
||||
jsonStr = strings.TrimSpace(jsonStr)
|
||||
|
||||
// Remove ```json ... ``` wrapper
|
||||
if strings.HasPrefix(jsonStr, "```json") {
|
||||
jsonStr = strings.TrimPrefix(jsonStr, "```json")
|
||||
jsonStr = strings.TrimSuffix(jsonStr, "```")
|
||||
jsonStr = strings.TrimSpace(jsonStr)
|
||||
} else if strings.HasPrefix(jsonStr, "```") {
|
||||
jsonStr = strings.TrimPrefix(jsonStr, "```")
|
||||
jsonStr = strings.TrimSuffix(jsonStr, "```")
|
||||
jsonStr = strings.TrimSpace(jsonStr)
|
||||
}
|
||||
|
||||
// Try direct JSON parse
|
||||
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
|
||||
// Try to find the first { ... } block in the content
|
||||
startIdx := strings.Index(jsonStr, "{")
|
||||
endIdx := strings.LastIndex(jsonStr, "}")
|
||||
if startIdx >= 0 && endIdx > startIdx {
|
||||
jsonBlock := jsonStr[startIdx : endIdx+1]
|
||||
if err2 := json.Unmarshal([]byte(jsonBlock), target); err2 != nil {
|
||||
return fmt.Errorf("JSON parse failed: %v (original: %v)", err2, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("JSON parse failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractParticipantInfoFromText is a fallback parser for participant analysis
|
||||
// when the LLM doesn't return proper JSON.
|
||||
func extractParticipantInfoFromText(content string) ParticipantAnalysisResult {
|
||||
var result ParticipantAnalysisResult
|
||||
lines := strings.Split(content, "\n")
|
||||
var currentParticipant *ParticipantInfo
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to detect participant sections
|
||||
if strings.Contains(line, "Participant") || strings.Contains(line, "Customer") || strings.Contains(line, "Agent") {
|
||||
if currentParticipant != nil {
|
||||
result.Participants = append(result.Participants, *currentParticipant)
|
||||
}
|
||||
currentParticipant = &ParticipantInfo{}
|
||||
if strings.Contains(line, "Customer") {
|
||||
currentParticipant.Role = "customer"
|
||||
} else if strings.Contains(line, "Agent") {
|
||||
currentParticipant.Role = "agent"
|
||||
}
|
||||
// Try to extract name
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
currentParticipant.Name = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
currentParticipant.Name = strings.TrimSpace(strings.ReplaceAll(line, "Participant", ""))
|
||||
}
|
||||
} else if currentParticipant != nil {
|
||||
// Try to extract role, sentiment, etc. from key-value pairs
|
||||
if strings.Contains(line, "Role:") || strings.Contains(line, "role:") {
|
||||
currentParticipant.Role = extractValueAfterColon(line)
|
||||
} else if strings.Contains(line, "Sentiment:") || strings.Contains(line, "sentiment:") {
|
||||
currentParticipant.Sentiment = extractValueAfterColon(line)
|
||||
} else if strings.Contains(line, "Topics:") || strings.Contains(line, "topics:") {
|
||||
topicsStr := extractValueAfterColon(line)
|
||||
topics := strings.Split(topicsStr, ",")
|
||||
for i, t := range topics {
|
||||
topics[i] = strings.TrimSpace(t)
|
||||
}
|
||||
currentParticipant.Topics = topics
|
||||
}
|
||||
} else {
|
||||
// Accumulate as summary text
|
||||
result.Summary += line + " "
|
||||
}
|
||||
}
|
||||
|
||||
if currentParticipant != nil {
|
||||
result.Participants = append(result.Participants, *currentParticipant)
|
||||
}
|
||||
result.Summary = strings.TrimSpace(result.Summary)
|
||||
|
||||
// Ensure we always have at least empty arrays
|
||||
if result.Participants == nil {
|
||||
result.Participants = []ParticipantInfo{}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractActionItemsFromText is a fallback parser for action items
|
||||
// when the LLM doesn't return proper JSON.
|
||||
func extractActionItemsFromText(content string) ActionItemsResult {
|
||||
var result ActionItemsResult
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip numbering/bullet prefixes
|
||||
line = stripListPrefix(line)
|
||||
|
||||
// Try to detect action item patterns
|
||||
if strings.Contains(line, "TODO") || strings.Contains(line, "Action") ||
|
||||
strings.Contains(line, "need to") || strings.Contains(line, "should") ||
|
||||
strings.Contains(line, "must") || strings.Contains(line, "follow up") {
|
||||
item := ActionItem{
|
||||
Description: line,
|
||||
Priority: "medium",
|
||||
Status: "pending",
|
||||
}
|
||||
// Try to extract priority keywords
|
||||
if strings.Contains(line, "urgent") || strings.Contains(line, "immediately") {
|
||||
item.Priority = "high"
|
||||
} else if strings.Contains(line, "low priority") {
|
||||
item.Priority = "low"
|
||||
}
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Items == nil {
|
||||
result.Items = []ActionItem{}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractLabelSuggestionFromText is a fallback parser for label suggestions
|
||||
// when the LLM doesn't return proper JSON.
|
||||
func extractLabelSuggestionFromText(content string) LabelSuggestionResult {
|
||||
var result LabelSuggestionResult
|
||||
var parsedLabels []string
|
||||
var priority, reason string
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, "Label") || strings.Contains(line, "label") ||
|
||||
strings.Contains(line, "Tag") || strings.Contains(line, "tag") {
|
||||
value := extractValueAfterColon(line)
|
||||
if value != "" {
|
||||
labels := strings.Split(value, ",")
|
||||
for _, l := range labels {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
parsedLabels = append(parsedLabels, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(line, "Priority") || strings.Contains(line, "priority") {
|
||||
priority = extractValueAfterColon(line)
|
||||
} else if strings.Contains(line, "Reason") || strings.Contains(line, "reason") {
|
||||
reason = extractValueAfterColon(line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parsedLabels) > 0 || priority != "" || reason != "" {
|
||||
result.Suggestions = append(result.Suggestions, ConversationLabelSuggestion{
|
||||
Labels: parsedLabels,
|
||||
Priority: priority,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractValueAfterColon extracts the value portion from a "key: value" line.
|
||||
func extractValueAfterColon(line string) string {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripListPrefix removes numbered or bullet list prefixes from a line.
|
||||
func stripListPrefix(line string) string {
|
||||
prefixes := []string{"1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9.", "10.",
|
||||
"1)", "2)", "3)", "4)", "5)", "- ", "* ", "• "}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
Reference in New Issue
Block a user