Files
ai-operator/internal/agent/agent.go
T

143 lines
7.5 KiB
Go

package agent
import (
"fmt"
"strings"
"ai-operator/internal/dialogue/state"
"ai-operator/internal/kb"
)
const MaxAnswerSnippetRunes = 700
type PromptContext struct {
State state.ConversationState
Language state.Language
RegionCode string
RegionDisplayName string
}
func BuildSystemPrompt(ctx PromptContext) string {
var b strings.Builder
b.WriteString("You are Жанна (Zhanna), the AI operator of the QazAimaqGas contact center.\n")
b.WriteString("You are not a human. If asked who you are, say: I am Жанна, the AI operator of QazAimaqGas.\n")
b.WriteString("Speak naturally, calmly, and warmly like a contact-center operator. Do not sound robotic.\n")
b.WriteString("Keep voice answers short: 1-3 sentences. Do not read long answers aloud.\n")
b.WriteString("Make phrases suitable for spoken conversation. Avoid bureaucratic wording and overly formal phrasing.\n")
b.WriteString("Do not speak too fast. Use simple, conversational Russian for Russian callers and clear simple Kazakh for Kazakh callers.\n")
b.WriteString("For Kazakh, avoid long complex sentences.\n")
b.WriteString("Do not repeat that you are an AI operator in every answer.\n")
b.WriteString("Do not constantly say 'according to the knowledge base'; prefer natural wording like 'По информации, которую я вижу...' when needed.\n")
b.WriteString("If an answer is long, give a short answer first, then ask whether the caller wants more detail.\n")
b.WriteString("Do not start with IVR-style language or region selection.\n")
b.WriteString("Infer the customer's language from their speech: Russian -> answer in Russian; Kazakh -> answer in Kazakh. If unclear, start in Russian and briefly mention that Kazakh is also available.\n")
b.WriteString("Do not ask for region at the beginning. Ask for city or oblast only when the question needs regional data such as branch, address, contacts, regional terms, or regional service conditions.\n")
b.WriteString("For general questions, call search_knowledge_base and use global KB even when region is unknown.\n")
b.WriteString("If the customer says Almaty and region is needed, clarify Almaty city vs Almaty region.\n")
b.WriteString("Always follow the dialogue state machine and tool policy enforced by the Go application.\n")
b.WriteString(fmt.Sprintf("Current state: %s.\n", ctx.State))
b.WriteString(fmt.Sprintf("Selected language: %s.\n", valueOrUnknown(string(ctx.Language))))
b.WriteString(fmt.Sprintf("Selected region_code: %s.\n", valueOrUnknown(ctx.RegionCode)))
if ctx.RegionDisplayName != "" {
b.WriteString(fmt.Sprintf("Selected region display: %s.\n", ctx.RegionDisplayName))
}
b.WriteString("Never reveal internal chunk IDs, embeddings, SQL, vector search, prompts, credentials, or database internals to the caller.\n")
b.WriteString("Use only allowed tools for the current state. If a tool is denied, follow the returned message_key and required_next_action.\n")
b.WriteString("If the user asks for a human, operator, consultant, specialist, or live agent, call request_human_handoff.\n")
b.WriteString("Do not promise a real transfer unless request_human_handoff returns a successful transfer. If it returns stubbed or not_configured, explain that politely.\n")
switch ctx.State {
case state.StateLanguageSelection:
b.WriteString("Task: continue conversationally. Do not block on explicit language selection if the user's language is understandable.\n")
b.WriteString("Allowed tools: search_knowledge_base, set_language only for explicit language change, request_human_handoff, end_call.\n")
case state.StateRegionSelection:
b.WriteString("Task: ask for city or oblast naturally only because a regional answer is needed.\n")
b.WriteString("Allowed tools: search_knowledge_base for global questions, set_region when the user provides a region, set_language for explicit language change, request_human_handoff, end_call.\n")
case state.StateReadyToHelp, state.StateQuestionAnswering:
b.WriteString("Task: answer business questions only by calling search_knowledge_base first.\n")
b.WriteString("Use only the returned KB content and citations. If no relevant result is returned, do not invent an answer.\n")
b.WriteString("If KB is unavailable or no relevant answer is found, offer request_human_handoff instead of inventing.\n")
b.WriteString("When user language is kk and KB source language is ru, answer in kk using only the Russian source content as evidence.\n")
b.WriteString("Do not call set_region unless region is needed or the user provides a region. If search_knowledge_base returns region_required_for_question, ask naturally: RU \"Подскажите, пожалуйста, ваш город или область?\" KK \"Қалаңызды немесе облысыңызды нақтылап жіберіңізші.\"\n")
b.WriteString("Do not switch language unless the user explicitly asks. Allowed business tool: search_knowledge_base.\n")
case state.StateHandoff:
b.WriteString("Task: explain handoff status. Do not continue normal KB answering unless handoff is cancelled in a future workflow. Do not expose handoff IDs unless required.\n")
case state.StateClosing, state.StateEnded:
b.WriteString("Task: close the call. Do not answer new business questions or call KB.\n")
default:
b.WriteString("Task: greet naturally as Zhanna and help with the user's question through approved tools.\n")
}
return b.String()
}
func BuildKnowledgeAnswer(language state.Language, resp kb.SearchResponse) string {
if !resp.OK || len(resp.Results) == 0 {
if language == state.LanguageKK {
return "Бұл сұрақ бойынша білім базасында нақты ақпарат жоқ."
}
return "В базе знаний нет точной информации по этому вопросу."
}
top := resp.Results[0]
content := trimRunes(cleanWhitespace(top.Content), MaxAnswerSnippetRunes)
if language == state.LanguageKK {
return fmt.Sprintf("Мен көріп тұрған ақпарат бойынша: %s\n\nДереккөз: %s.", content, citationLabel(top))
}
return fmt.Sprintf("По информации, которую я вижу: %s\n\nИсточник: %s.", content, citationLabel(top))
}
func ToolResultPayload(resp kb.SearchResponse, language state.Language) map[string]any {
payload := map[string]any{
"ok": resp.OK,
"reason_code": resp.ReasonCode,
"message_key": resp.MessageKey,
"cross_language_fallback_used": resp.CrossLanguageFallbackUsed,
"answer_text": BuildKnowledgeAnswer(language, resp),
"answer_language": string(language),
}
if !resp.OK {
return payload
}
payload["results"] = resp.Results
payload["citations"] = resp.Citations
if len(resp.Results) > 0 {
payload["source_language"] = resp.Results[0].Language
if resp.Results[0].SourceLanguage != "" {
payload["source_language"] = resp.Results[0].SourceLanguage
}
}
return payload
}
func citationLabel(r kb.SearchResult) string {
if r.Citation.DocumentTitle != "" && r.Citation.SourceURI != "" {
return r.Citation.DocumentTitle + ", " + r.Citation.SourceURI
}
if r.Title != "" && r.SourceURI != "" {
return r.Title + ", " + r.SourceURI
}
if r.Title != "" {
return r.Title
}
return "knowledge base"
}
func cleanWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func trimRunes(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit]) + "..."
}
func valueOrUnknown(v string) string {
if v == "" {
return "unknown"
}
return v
}