eriquesouza commited on
Commit
b7b030d
·
1 Parent(s): fb06dfe

Enhance agent and models for improved JSON handling and memory type normalization

Browse files

- Updated `agent.py` to include a new function for parsing LLM output, ensuring strict JSON compliance and handling various output formats.
- Modified response format in `agent.py` to clarify JSON structure requirements.
- Enhanced `models.py` with a normalization function for memory types, allowing for flexible input handling and improved validation.
- Introduced a field validator in `NewMemoryItem` to ensure correct memory type coercion during model instantiation.

Files changed (2) hide show
  1. agent.py +49 -6
  2. models.py +31 -2
agent.py CHANGED
@@ -1,7 +1,11 @@
 
1
  import os
 
 
2
  from typing import List, Dict
3
 
4
  from dotenv import load_dotenv
 
5
  from langchain_core.messages import AIMessage, HumanMessage
6
  from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
7
  from langchain_openai import ChatOpenAI
@@ -31,7 +35,7 @@ RESPONSE FORMAT — you MUST return valid JSON only, no other text:
31
  "new_memories": [
32
  {{
33
  "content": "What the current customer said, preserved in their voice as closely as possible",
34
- "type": "episodic|semantic|state|procedural",
35
  "context_tags": ["tag1", "tag2"],
36
  "summary": "5-10 word summary for display"
37
  }}
@@ -60,6 +64,8 @@ current-sounding situation (state), or insight about what helped/hurt in support
60
  logical troubleshooting steps, and reasonable next steps without inventing protocol numbers, \
61
  discounts, stock levels, or coverage
62
  - Respond ONLY with the JSON object — no preamble, no markdown fences
 
 
63
  """
64
 
65
  OPENROUTER_API_URL = os.getenv("OPENROUTER_API_URL")
@@ -72,6 +78,41 @@ PROMPT = ChatPromptTemplate.from_messages([
72
  ])
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  class Agent:
76
  def __init__(self, memory_store: MemoryStore):
77
  self.memory_store = memory_store
@@ -81,15 +122,16 @@ class Agent:
81
  model=MODEL,
82
  max_tokens=1500,
83
  timeout=60.0,
84
- extra_body={"chat_template_kwargs": {"enable_thinking": False}},
 
 
 
85
  default_headers={
86
  "HTTP-Referer": "http://localhost:8000",
87
  "X-Title": "Agent Memory Phase 1",
88
  },
89
  )
90
- self.chain = PROMPT | llm.with_structured_output(
91
- AgentLLMOutput, method="json_mode"
92
- )
93
 
94
  async def chat(
95
  self,
@@ -115,11 +157,12 @@ class Agent:
115
  for turn in conversation_history[-6:]
116
  ]
117
 
118
- parsed: AgentLLMOutput = await self.chain.ainvoke({
119
  "memory_context": memory_context,
120
  "input": message,
121
  "history": history,
122
  })
 
123
 
124
  for mem_id in parsed.memories_used:
125
  self.memory_store.update_access(mem_id)
 
1
+ import json
2
  import os
3
+ import re
4
+ from json import JSONDecoder
5
  from typing import List, Dict
6
 
7
  from dotenv import load_dotenv
8
+ from pydantic import ValidationError
9
  from langchain_core.messages import AIMessage, HumanMessage
10
  from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
11
  from langchain_openai import ChatOpenAI
 
35
  "new_memories": [
36
  {{
37
  "content": "What the current customer said, preserved in their voice as closely as possible",
38
+ "type": "episodic|semantic|state|procedural (English only, not episodico/semantico)",
39
  "context_tags": ["tag1", "tag2"],
40
  "summary": "5-10 word summary for display"
41
  }}
 
64
  logical troubleshooting steps, and reasonable next steps without inventing protocol numbers, \
65
  discounts, stock levels, or coverage
66
  - Respond ONLY with the JSON object — no preamble, no markdown fences
67
+ - Previous assistant turns in the chat history are plain-text summaries for context; \
68
+ your current reply must still be ONLY the JSON object, never duplicate the answer outside JSON
69
  """
70
 
71
  OPENROUTER_API_URL = os.getenv("OPENROUTER_API_URL")
 
78
  ])
79
 
80
 
81
+ def _parse_agent_llm_output(text: str) -> AgentLLMOutput:
82
+ """Accept strict JSON or model output with prose before/after the JSON object."""
83
+ text = (text or "").strip()
84
+ if not text:
85
+ raise ValueError("Empty LLM output")
86
+
87
+ decoder = JSONDecoder()
88
+ candidates: List[str] = [text]
89
+ for match in re.finditer(
90
+ r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL | re.IGNORECASE
91
+ ):
92
+ candidates.append(match.group(1))
93
+
94
+ for raw in candidates:
95
+ try:
96
+ return AgentLLMOutput.model_validate(json.loads(raw))
97
+ except (json.JSONDecodeError, ValueError, ValidationError):
98
+ continue
99
+
100
+ start = 0
101
+ while True:
102
+ brace = text.find("{", start)
103
+ if brace == -1:
104
+ break
105
+ try:
106
+ obj, _ = decoder.raw_decode(text, brace)
107
+ if isinstance(obj, dict) and "response" in obj:
108
+ return AgentLLMOutput.model_validate(obj)
109
+ except (json.JSONDecodeError, ValidationError):
110
+ pass
111
+ start = brace + 1
112
+
113
+ raise ValueError("No valid AgentLLMOutput JSON found in model response")
114
+
115
+
116
  class Agent:
117
  def __init__(self, memory_store: MemoryStore):
118
  self.memory_store = memory_store
 
122
  model=MODEL,
123
  max_tokens=1500,
124
  timeout=60.0,
125
+ extra_body={
126
+ "chat_template_kwargs": {"enable_thinking": False},
127
+ "response_format": {"type": "json_object"},
128
+ },
129
  default_headers={
130
  "HTTP-Referer": "http://localhost:8000",
131
  "X-Title": "Agent Memory Phase 1",
132
  },
133
  )
134
+ self.chain = PROMPT | llm
 
 
135
 
136
  async def chat(
137
  self,
 
157
  for turn in conversation_history[-6:]
158
  ]
159
 
160
+ raw = await self.chain.ainvoke({
161
  "memory_context": memory_context,
162
  "input": message,
163
  "history": history,
164
  })
165
+ parsed = _parse_agent_llm_output(raw.content)
166
 
167
  for mem_id in parsed.memories_used:
168
  self.memory_store.update_access(mem_id)
models.py CHANGED
@@ -1,6 +1,8 @@
1
- from pydantic import BaseModel
2
- from typing import List, Optional, Dict, Any
3
  from enum import Enum
 
 
 
4
 
5
 
6
  class MemoryType(str, Enum):
@@ -10,6 +12,28 @@ class MemoryType(str, Enum):
10
  PROCEDURAL = "procedural"
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class Memory(BaseModel):
14
  id: str
15
  content: str
@@ -31,6 +55,11 @@ class NewMemoryItem(BaseModel):
31
  context_tags: List[str] = []
32
  summary: str = ""
33
 
 
 
 
 
 
34
 
35
  class AgentLLMOutput(BaseModel):
36
  response: str
 
1
+ import unicodedata
 
2
  from enum import Enum
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, field_validator
6
 
7
 
8
  class MemoryType(str, Enum):
 
12
  PROCEDURAL = "procedural"
13
 
14
 
15
+ _MEMORY_TYPE_ALIASES = {
16
+ "episodico": MemoryType.EPISODIC,
17
+ "episodic": MemoryType.EPISODIC,
18
+ "semantico": MemoryType.SEMANTIC,
19
+ "semantic": MemoryType.SEMANTIC,
20
+ "estado": MemoryType.STATE,
21
+ "state": MemoryType.STATE,
22
+ "procedural": MemoryType.PROCEDURAL,
23
+ "procedimental": MemoryType.PROCEDURAL,
24
+ }
25
+
26
+
27
+ def normalize_memory_type(value: Any) -> MemoryType:
28
+ if isinstance(value, MemoryType):
29
+ return value
30
+ if value is None or (isinstance(value, str) and not value.strip()):
31
+ return MemoryType.SEMANTIC
32
+ key = unicodedata.normalize("NFKD", str(value).strip().lower())
33
+ key = "".join(c for c in key if not unicodedata.combining(c))
34
+ return _MEMORY_TYPE_ALIASES.get(key, MemoryType.SEMANTIC)
35
+
36
+
37
  class Memory(BaseModel):
38
  id: str
39
  content: str
 
55
  context_tags: List[str] = []
56
  summary: str = ""
57
 
58
+ @field_validator("type", mode="before")
59
+ @classmethod
60
+ def coerce_memory_type(cls, value: Any) -> MemoryType:
61
+ return normalize_memory_type(value)
62
+
63
 
64
  class AgentLLMOutput(BaseModel):
65
  response: str