misukisu commited on
Commit
e217b55
·
verified ·
1 Parent(s): 8cf555b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +134 -46
app.py CHANGED
@@ -2,7 +2,9 @@ import os
2
  import json
3
  import ast
4
  import math
 
5
  import asyncio
 
6
  from collections import defaultdict, deque
7
 
8
  import wikipedia
@@ -22,47 +24,50 @@ from transformers import AutoTokenizer, AutoModelForCausalLM
22
  MODEL_NAME = "Qwen/Qwen2-1.5B-Instruct"
23
  MAX_HISTORY = 12
24
  MAX_STEPS = 4
 
 
 
25
 
26
  tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
27
  model = AutoModelForCausalLM.from_pretrained(
28
  MODEL_NAME,
29
- torch_dtype="auto",
30
- device_map="auto"
 
31
  )
 
32
 
33
  memory = defaultdict(lambda: deque(maxlen=MAX_HISTORY))
34
  button_state = defaultdict(dict)
35
 
36
- SYSTEM_PROMPT = """
37
  You are an advanced Telegram AI agent.
38
 
39
  You must never expose internal tool calls, JSON planning, scratch work, or control tokens to the user.
40
 
41
- You have three possible response formats.
42
 
43
- 1) Final answer:
44
  {"type":"final","text":"your message to the user"}
45
 
46
  2) Buttons:
47
  {"type":"buttons","text":"question for the user","buttons":[{"id":"choice_1","label":"Yes"},{"id":"choice_2","label":"No"}]}
48
 
49
- 3) Tool call:
50
  {"type":"tool","name":"wiki_search","arguments":{"query":"Finland"}}
51
-
52
- or
53
-
54
  {"type":"tool","name":"calculate","arguments":{"expression":"(25*17)/5"}}
55
 
56
  Rules:
57
  - Output exactly one JSON object.
58
  - No markdown fences.
59
  - No extra text before or after JSON.
60
- - Use buttons when the user should choose between short options.
61
- - Use wiki_search for factual lookups, topics, places, people, concepts, summaries.
 
62
  - Use calculate for arithmetic or formula evaluation.
63
- - After receiving a tool result, continue and produce either another tool call, buttons, or a final answer.
 
64
  - Prefer Finnish if the user speaks Finnish.
65
- - Keep answers clean and natural.
66
  - Never output tokens like <|end|>, <|im_start|>, <|im_end|>.
67
  """
68
 
@@ -103,7 +108,10 @@ def safe_calculate(expression: str) -> str:
103
  ast.List,
104
  )
105
 
106
- tree = ast.parse(expression, mode="eval")
 
 
 
107
 
108
  for node in ast.walk(tree):
109
  if not isinstance(node, allowed_nodes):
@@ -144,54 +152,62 @@ def wiki_search(query: str) -> str:
144
  page = wikipedia.page(query, auto_suggest=True)
145
  return page.summary[:1200]
146
  except Exception:
147
- return f"En löytänyt Wikipedia-tulosta haulle: {query}"
148
 
149
  TOOLS = {
150
  "wiki_search": wiki_search,
151
  "calculate": safe_calculate,
152
  }
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def extract_json(text: str):
155
  text = text.strip()
156
  decoder = json.JSONDecoder()
157
-
158
  for i, ch in enumerate(text):
159
  if ch == "{":
160
  try:
161
  obj, end = decoder.raw_decode(text[i:])
162
  trailing = text[i + end:].strip()
163
  if trailing:
164
- return None
165
  return obj
166
  except Exception:
167
  continue
168
  return None
169
 
170
- def clean_text(text: str) -> str:
171
- bad = ["<|end|>", "<|im_start|>", "<|im_end|>", "<|assistant|>", "<|user|>", "<|system|>"]
172
- for token in bad:
173
- text = text.replace(token, "")
174
- return text.strip()
175
-
176
- def build_messages(user_id: int, user_text: str):
177
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
178
  for item in memory[user_id]:
179
  messages.append(item)
180
  messages.append({"role": "user", "content": user_text})
181
  return messages
182
 
183
- def generate_json_response(messages):
184
  prompt = tokenizer.apply_chat_template(
185
  messages,
186
  tokenize=False,
187
  add_generation_prompt=True
188
  )
189
- inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
190
  with torch.no_grad():
191
  outputs = model.generate(
192
  **inputs,
193
- max_new_tokens=220,
194
  do_sample=False,
 
195
  pad_token_id=tokenizer.eos_token_id
196
  )
197
  new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
@@ -199,14 +215,19 @@ def generate_json_response(messages):
199
  return clean_text(text)
200
 
201
  def run_agent(user_id: int, user_text: str):
202
- messages = build_messages(user_id, user_text)
203
 
204
  for _ in range(MAX_STEPS):
205
- raw = generate_json_response(messages)
206
  data = extract_json(raw)
207
 
208
  if not isinstance(data, dict):
209
- return {"type": "final", "text": clean_text(raw)}
 
 
 
 
 
210
 
211
  response_type = data.get("type")
212
 
@@ -220,15 +241,21 @@ def run_agent(user_id: int, user_text: str):
220
  text = clean_text(str(data.get("text", "")))
221
  buttons = data.get("buttons", [])
222
  normalized = []
223
- for b in buttons[:6]:
224
- if isinstance(b, dict):
225
- bid = str(b.get("id", "")).strip()
226
- label = str(b.get("label", "")).strip()
227
- if bid and label:
228
- normalized.append({"id": bid[:32], "label": label[:40]})
 
229
  if text and normalized:
230
  return {"type": "buttons", "text": text, "buttons": normalized}
231
- return {"type": "final", "text": "Valintoja ei voitu muodostaa oikein."}
 
 
 
 
 
232
 
233
  if response_type == "tool":
234
  name = data.get("name")
@@ -256,7 +283,11 @@ def run_agent(user_id: int, user_text: str):
256
  })
257
  continue
258
 
259
- return {"type": "final", "text": "Sain epäkelvon agenttivastauksen."}
 
 
 
 
260
 
261
  return {"type": "final", "text": "Pyyntö vaati liikaa välivaiheita."}
262
 
@@ -267,13 +298,62 @@ async def typing_loop(chat, stop_event: asyncio.Event):
267
  except Exception:
268
  return
269
  try:
270
- await asyncio.wait_for(stop_event.wait(), timeout=4.0)
271
  except asyncio.TimeoutError:
272
  pass
273
 
274
- async def process_user_text(update_or_query_message, context: ContextTypes.DEFAULT_TYPE, user_id: int, text: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  stop_event = asyncio.Event()
276
- typing_task = asyncio.create_task(typing_loop(update_or_query_message.chat, stop_event))
 
277
  try:
278
  result = await asyncio.to_thread(run_agent, user_id, text)
279
  finally:
@@ -285,6 +365,7 @@ async def process_user_text(update_or_query_message, context: ContextTypes.DEFAU
285
  if result["type"] == "buttons":
286
  keyboard = []
287
  button_state[user_id] = {}
 
288
  for b in result["buttons"]:
289
  button_state[user_id][b["id"]] = b["label"]
290
  keyboard.append([InlineKeyboardButton(b["label"], callback_data=f"btn:{b['id']}")])
@@ -297,22 +378,28 @@ async def process_user_text(update_or_query_message, context: ContextTypes.DEFAU
297
  )
298
  })
299
 
300
- await update_or_query_message.reply_text(
301
  result["text"],
302
  reply_markup=InlineKeyboardMarkup(keyboard)
303
  )
304
  return
305
 
306
  reply = clean_text(result["text"])
307
- memory[user_id].append({"role": "assistant", "content": json.dumps({"type": "final", "text": reply}, ensure_ascii=False)})
308
- await update_or_query_message.reply_text(reply)
 
 
 
 
309
 
310
  async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
311
  if not update.message or not update.effective_user:
312
  return
 
313
  text = (update.message.text or "").strip()
314
  if not text:
315
  return
 
316
  await process_user_text(update.message, context, update.effective_user.id, text)
317
 
318
  async def handle_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -339,7 +426,7 @@ async def handle_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
339
  async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
340
  if not update.message:
341
  return
342
- await update.message.reply_text("Moi. Olen AI-agentti. Voit kysyä mitä vain.")
343
 
344
  async def reset_chat(update: Update, context: ContextTypes.DEFAULT_TYPE):
345
  if not update.message or not update.effective_user:
@@ -351,6 +438,7 @@ async def reset_chat(update: Update, context: ContextTypes.DEFAULT_TYPE):
351
 
352
  def main():
353
  token = os.environ["TELEGRAM_TOKEN"]
 
354
  app = ApplicationBuilder().token(token).build()
355
 
356
  app.add_handler(CommandHandler("start", start))
 
2
  import json
3
  import ast
4
  import math
5
+ import time
6
  import asyncio
7
+ import threading
8
  from collections import defaultdict, deque
9
 
10
  import wikipedia
 
24
  MODEL_NAME = "Qwen/Qwen2-1.5B-Instruct"
25
  MAX_HISTORY = 12
26
  MAX_STEPS = 4
27
+ MAX_NEW_TOKENS_JSON = 220
28
+
29
+ torch.set_num_threads(max(1, os.cpu_count() // 2))
30
 
31
  tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
32
  model = AutoModelForCausalLM.from_pretrained(
33
  MODEL_NAME,
34
+ torch_dtype=torch.float32,
35
+ device_map="cpu",
36
+ low_cpu_mem_usage=True
37
  )
38
+ model.eval()
39
 
40
  memory = defaultdict(lambda: deque(maxlen=MAX_HISTORY))
41
  button_state = defaultdict(dict)
42
 
43
+ PLANNER_SYSTEM_PROMPT = """
44
  You are an advanced Telegram AI agent.
45
 
46
  You must never expose internal tool calls, JSON planning, scratch work, or control tokens to the user.
47
 
48
+ You have exactly three response formats.
49
 
50
+ 1) Final:
51
  {"type":"final","text":"your message to the user"}
52
 
53
  2) Buttons:
54
  {"type":"buttons","text":"question for the user","buttons":[{"id":"choice_1","label":"Yes"},{"id":"choice_2","label":"No"}]}
55
 
56
+ 3) Tool:
57
  {"type":"tool","name":"wiki_search","arguments":{"query":"Finland"}}
 
 
 
58
  {"type":"tool","name":"calculate","arguments":{"expression":"(25*17)/5"}}
59
 
60
  Rules:
61
  - Output exactly one JSON object.
62
  - No markdown fences.
63
  - No extra text before or after JSON.
64
+ - The button type must be exactly "buttons".
65
+ - Use buttons when the user should choose between a few short options.
66
+ - Use wiki_search for factual topics, places, people, concepts, summaries.
67
  - Use calculate for arithmetic or formula evaluation.
68
+ - If you are unsure, do not call a tool. Respond with a final answer instead.
69
+ - After receiving a tool result, continue and respond with exactly one JSON object.
70
  - Prefer Finnish if the user speaks Finnish.
 
71
  - Never output tokens like <|end|>, <|im_start|>, <|im_end|>.
72
  """
73
 
 
108
  ast.List,
109
  )
110
 
111
+ try:
112
+ tree = ast.parse(expression, mode="eval")
113
+ except Exception:
114
+ return "Virhe: laskua ei voitu lukea."
115
 
116
  for node in ast.walk(tree):
117
  if not isinstance(node, allowed_nodes):
 
152
  page = wikipedia.page(query, auto_suggest=True)
153
  return page.summary[:1200]
154
  except Exception:
155
+ return f"En löytänyt hakutulosta haulle: {query}"
156
 
157
  TOOLS = {
158
  "wiki_search": wiki_search,
159
  "calculate": safe_calculate,
160
  }
161
 
162
+ def clean_text(text: str) -> str:
163
+ bad_tokens = [
164
+ "<|end|>",
165
+ "<|im_start|>",
166
+ "<|im_end|>",
167
+ "<|assistant|>",
168
+ "<|user|>",
169
+ "<|system|>",
170
+ "</s>",
171
+ ]
172
+ for token in bad_tokens:
173
+ text = text.replace(token, "")
174
+ return text.strip()
175
+
176
  def extract_json(text: str):
177
  text = text.strip()
178
  decoder = json.JSONDecoder()
 
179
  for i, ch in enumerate(text):
180
  if ch == "{":
181
  try:
182
  obj, end = decoder.raw_decode(text[i:])
183
  trailing = text[i + end:].strip()
184
  if trailing:
185
+ continue
186
  return obj
187
  except Exception:
188
  continue
189
  return None
190
 
191
+ def build_planner_messages(user_id: int, user_text: str):
192
+ messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT}]
 
 
 
 
 
 
193
  for item in memory[user_id]:
194
  messages.append(item)
195
  messages.append({"role": "user", "content": user_text})
196
  return messages
197
 
198
+ def generate_chat_text(messages, max_new_tokens=220):
199
  prompt = tokenizer.apply_chat_template(
200
  messages,
201
  tokenize=False,
202
  add_generation_prompt=True
203
  )
204
+ inputs = tokenizer(prompt, return_tensors="pt")
205
  with torch.no_grad():
206
  outputs = model.generate(
207
  **inputs,
208
+ max_new_tokens=max_new_tokens,
209
  do_sample=False,
210
+ use_cache=True,
211
  pad_token_id=tokenizer.eos_token_id
212
  )
213
  new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
 
215
  return clean_text(text)
216
 
217
  def run_agent(user_id: int, user_text: str):
218
+ messages = build_planner_messages(user_id, user_text)
219
 
220
  for _ in range(MAX_STEPS):
221
+ raw = generate_chat_text(messages, max_new_tokens=MAX_NEW_TOKENS_JSON)
222
  data = extract_json(raw)
223
 
224
  if not isinstance(data, dict):
225
+ messages.append({"role": "assistant", "content": raw})
226
+ messages.append({
227
+ "role": "user",
228
+ "content": 'Your previous response was invalid. Output ONLY one valid JSON object.'
229
+ })
230
+ continue
231
 
232
  response_type = data.get("type")
233
 
 
241
  text = clean_text(str(data.get("text", "")))
242
  buttons = data.get("buttons", [])
243
  normalized = []
244
+ if isinstance(buttons, list):
245
+ for b in buttons[:6]:
246
+ if isinstance(b, dict):
247
+ bid = str(b.get("id", "")).strip()
248
+ label = str(b.get("label", "")).strip()
249
+ if bid and label:
250
+ normalized.append({"id": bid[:32], "label": label[:40]})
251
  if text and normalized:
252
  return {"type": "buttons", "text": text, "buttons": normalized}
253
+ messages.append({"role": "assistant", "content": json.dumps(data, ensure_ascii=False)})
254
+ messages.append({
255
+ "role": "user",
256
+ "content": 'That buttons response was invalid. Output ONLY one valid JSON object.'
257
+ })
258
+ continue
259
 
260
  if response_type == "tool":
261
  name = data.get("name")
 
283
  })
284
  continue
285
 
286
+ messages.append({"role": "assistant", "content": json.dumps(data, ensure_ascii=False)})
287
+ messages.append({
288
+ "role": "user",
289
+ "content": 'That response type was invalid. Output ONLY one valid JSON object.'
290
+ })
291
 
292
  return {"type": "final", "text": "Pyyntö vaati liikaa välivaiheita."}
293
 
 
298
  except Exception:
299
  return
300
  try:
301
+ await asyncio.wait_for(stop_event.wait(), timeout=2.0)
302
  except asyncio.TimeoutError:
303
  pass
304
 
305
+ def chunk_text_for_stream(text: str):
306
+ words = text.split()
307
+ if not words:
308
+ return [""]
309
+
310
+ chunks = []
311
+ current = ""
312
+
313
+ for word in words:
314
+ candidate = f"{current} {word}".strip()
315
+ if len(candidate) >= 35:
316
+ chunks.append(candidate)
317
+ current = ""
318
+ else:
319
+ current = candidate
320
+
321
+ if current:
322
+ chunks.append(current)
323
+
324
+ return chunks
325
+
326
+ async def stream_text_reply(message, text: str):
327
+ text = clean_text(text)
328
+ if not text:
329
+ text = " "
330
+
331
+ chunks = chunk_text_for_stream(text)
332
+ sent = await message.reply_text("...")
333
+ assembled = ""
334
+ last_edit_time = 0.0
335
+
336
+ for i, chunk in enumerate(chunks):
337
+ assembled = f"{assembled} {chunk}".strip()
338
+ now = time.time()
339
+
340
+ if i < len(chunks) - 1:
341
+ if now - last_edit_time < 0.55:
342
+ await asyncio.sleep(0.55 - (now - last_edit_time))
343
+
344
+ safe_text = assembled[:4096]
345
+ try:
346
+ await sent.edit_text(safe_text)
347
+ last_edit_time = time.time()
348
+ except Exception:
349
+ pass
350
+
351
+ return sent
352
+
353
+ async def process_user_text(message, context: ContextTypes.DEFAULT_TYPE, user_id: int, text: str):
354
  stop_event = asyncio.Event()
355
+ typing_task = asyncio.create_task(typing_loop(message.chat, stop_event))
356
+
357
  try:
358
  result = await asyncio.to_thread(run_agent, user_id, text)
359
  finally:
 
365
  if result["type"] == "buttons":
366
  keyboard = []
367
  button_state[user_id] = {}
368
+
369
  for b in result["buttons"]:
370
  button_state[user_id][b["id"]] = b["label"]
371
  keyboard.append([InlineKeyboardButton(b["label"], callback_data=f"btn:{b['id']}")])
 
378
  )
379
  })
380
 
381
+ await message.reply_text(
382
  result["text"],
383
  reply_markup=InlineKeyboardMarkup(keyboard)
384
  )
385
  return
386
 
387
  reply = clean_text(result["text"])
388
+ memory[user_id].append({
389
+ "role": "assistant",
390
+ "content": json.dumps({"type": "final", "text": reply}, ensure_ascii=False)
391
+ })
392
+
393
+ await stream_text_reply(message, reply)
394
 
395
  async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
396
  if not update.message or not update.effective_user:
397
  return
398
+
399
  text = (update.message.text or "").strip()
400
  if not text:
401
  return
402
+
403
  await process_user_text(update.message, context, update.effective_user.id, text)
404
 
405
  async def handle_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
426
  async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
427
  if not update.message:
428
  return
429
+ await update.message.reply_text("Moi. Olen AI-agentti. Laita viestiä.")
430
 
431
  async def reset_chat(update: Update, context: ContextTypes.DEFAULT_TYPE):
432
  if not update.message or not update.effective_user:
 
438
 
439
  def main():
440
  token = os.environ["TELEGRAM_TOKEN"]
441
+
442
  app = ApplicationBuilder().token(token).build()
443
 
444
  app.add_handler(CommandHandler("start", start))