File size: 8,113 Bytes
b7b030d
0d61e07
b7b030d
 
0d61e07
 
 
b7b030d
fb06dfe
 
 
0d61e07
fb06dfe
0d61e07
 
 
 
2f56bf2
 
 
 
 
 
 
 
 
0d61e07
eba72b3
 
 
0d61e07
eba72b3
 
 
0d61e07
 
 
 
 
 
 
eba72b3
0d61e07
 
 
eba72b3
b7b030d
0d61e07
 
 
 
 
 
 
eba72b3
 
 
 
 
0d61e07
 
 
eba72b3
 
 
 
 
 
 
 
 
 
 
 
0d61e07
b7b030d
 
0d61e07
 
cc75af5
 
0d61e07
fb06dfe
 
 
 
 
 
 
b7b030d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d61e07
 
 
fb06dfe
 
 
 
 
 
b7b030d
 
 
 
fb06dfe
2f56bf2
fb06dfe
 
 
b7b030d
0d61e07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb06dfe
 
 
 
 
 
 
 
b7b030d
fb06dfe
 
 
 
b7b030d
fb06dfe
 
0d61e07
 
 
fb06dfe
 
 
 
 
 
 
 
 
0d61e07
 
fb06dfe
 
0d61e07
fb06dfe
0d61e07
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
import json
import os
import re
from json import JSONDecoder
from typing import List, Dict

from dotenv import load_dotenv
from pydantic import ValidationError
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI

from models import AgentLLMOutput, ChatResponse, Memory
from memory_store import MemoryStore

load_dotenv()


def _openrouter_referer() -> str:
    if host := os.getenv("SPACE_HOST"):
        return host
    if space_id := os.getenv("SPACE_ID"):
        return f"https://huggingface.co/spaces/{space_id}"
    return "http://localhost:8000"


SYSTEM_PROMPT = """\
You are a customer service assistant for an internet, TV, and telephony provider in Brazil.
This is a memory-observability PoC: you help the current customer resolve their situation using \
what past customers said in previous conversations β€” stored as memories below.

Memories are the customers' own words (or faithful paraphrase), NOT official policy, CRM data, \
or verified operational facts. Use them to empathize, recognize patterns, and suggest helpful \
approaches that worked before β€” with caution when memories conflict or may be outdated.

{memory_context}

─────────────────────────────────────────────
RESPONSE FORMAT β€” you MUST return valid JSON only, no other text:

{{
  "response": "Your reply to the customer in Brazilian Portuguese (clear, empathetic, actionable)",
  "memories_used": ["id1", "id2"],
  "new_memories": [
    {{
      "content": "What the current customer said, preserved in their voice as closely as possible",
      "type": "episodic|semantic|state|procedural (English only, not episodico/semantico)",
      "context_tags": ["tag1", "tag2"],
      "summary": "5-10 word summary for display"
    }}
  ]
}}

─────────────────────────────────────────────
MEMORY TYPES (content is usually a past customer's statement):
- episodic   β†’ specific situation a customer reported ("my portability has been stuck for 5 days")
- semantic   β†’ recurring pattern from multiple customers ("new installs often question real speed")
- state      β†’ recent claim about current conditions ("the app won't load my boleto since yesterday")
- procedural β†’ lesson from how support went ("asking cable vs Wi-Fi before sending a tech helped")

─────────────────────────────────────────────
GUIDELINES:
- Always respond to the customer in Brazilian Portuguese; be empathetic and avoid unexplained jargon
- Use only memories genuinely relevant to the current message
- List only the IDs of memories you actually drew on in your response
- When memories contradict each other, do NOT state uncertain things as fact; ask clarifying questions \
or acknowledge uncertainty
- Create new_memories only for noteworthy things the CURRENT customer said β€” keep their wording \
and tone; not every message needs a new memory
- Choose memory type based on what was said: one-off event (episodic), recurring theme (semantic), \
current-sounding situation (state), or insight about what helped/hurt in support (procedural)
- PoC limits: you have no access to billing, CRM, or network systems β€” guide with questions, \
logical troubleshooting steps, and reasonable next steps without inventing protocol numbers, \
discounts, stock levels, or coverage
- Respond ONLY with the JSON object β€” no preamble, no markdown fences
- Previous assistant turns in the chat history are plain-text summaries for context; \
your current reply must still be ONLY the JSON object, never duplicate the answer outside JSON
"""

OPENROUTER_API_URL = os.getenv("OPENROUTER_API_URL")
MODEL = os.getenv("OPENROUTER_MODEL")

PROMPT = ChatPromptTemplate.from_messages([
    ("system", SYSTEM_PROMPT),
    MessagesPlaceholder("history"),
    ("human", "{input}"),
])


def _parse_agent_llm_output(text: str) -> AgentLLMOutput:
    """Accept strict JSON or model output with prose before/after the JSON object."""
    text = (text or "").strip()
    if not text:
        raise ValueError("Empty LLM output")

    decoder = JSONDecoder()
    candidates: List[str] = [text]
    for match in re.finditer(
        r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL | re.IGNORECASE
    ):
        candidates.append(match.group(1))

    for raw in candidates:
        try:
            return AgentLLMOutput.model_validate(json.loads(raw))
        except (json.JSONDecodeError, ValueError, ValidationError):
            continue

    start = 0
    while True:
        brace = text.find("{", start)
        if brace == -1:
            break
        try:
            obj, _ = decoder.raw_decode(text, brace)
            if isinstance(obj, dict) and "response" in obj:
                return AgentLLMOutput.model_validate(obj)
        except (json.JSONDecodeError, ValidationError):
            pass
        start = brace + 1

    raise ValueError("No valid AgentLLMOutput JSON found in model response")


class Agent:
    def __init__(self, memory_store: MemoryStore):
        self.memory_store = memory_store
        llm = ChatOpenAI(
            base_url=OPENROUTER_API_URL,
            api_key=os.getenv("OPENROUTER_API_KEY"),
            model=MODEL,
            max_tokens=1500,
            timeout=60.0,
            extra_body={
                "chat_template_kwargs": {"enable_thinking": False},
                "response_format": {"type": "json_object"},
            },
            default_headers={
                "HTTP-Referer": _openrouter_referer(),
                "X-Title": "Agent Memory Phase 1",
            },
        )
        self.chain = PROMPT | llm

    async def chat(
        self,
        message: str,
        conversation_history: List[Dict[str, str]],
    ) -> ChatResponse:
        relevant = self.memory_store.search(message, n_results=6)
        state_mems = self.memory_store.search(message, n_results=3, type_filter="state")

        seen: set = set()
        candidates: List[Memory] = []
        for m in relevant + state_mems:
            if m.id not in seen:
                seen.add(m.id)
                candidates.append(m)

        memory_context = self._format_memories(candidates)

        history = [
            HumanMessage(content=turn["content"])
            if turn["role"] == "user"
            else AIMessage(content=turn["content"])
            for turn in conversation_history[-6:]
        ]

        raw = await self.chain.ainvoke({
            "memory_context": memory_context,
            "input": message,
            "history": history,
        })
        parsed = _parse_agent_llm_output(raw.content)

        for mem_id in parsed.memories_used:
            self.memory_store.update_access(mem_id)

        new_memories_saved: List[Memory] = []
        for nm in parsed.new_memories:
            saved = self.memory_store.add_memory(
                content=nm.content,
                memory_type=nm.type.value,
                source="agent",
                context_tags=nm.context_tags,
                summary=nm.summary,
            )
            new_memories_saved.append(saved)

        return ChatResponse(
            response=parsed.response,
            memories_used=parsed.memories_used,
            new_memories=new_memories_saved,
            all_memories=self.memory_store.list_memories(),
        )

    def _format_memories(self, memories: List[Memory]) -> str:
        if not memories:
            return "(no memories available)"
        lines = []
        for m in memories:
            tags = ", ".join(m.context_tags) if m.context_tags else "β€”"
            lines.append(
                f"[ID: {m.id}] [{m.type.upper()}] {m.content}\n"
                f"  tags: {tags} | score: {m.relevance_score:.2f} | accessed: {m.access_count}x"
            )
        return "\n\n".join(lines)