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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +329 -136
app.py CHANGED
@@ -1,171 +1,364 @@
1
  import os
2
  import json
 
3
  import math
 
 
 
 
 
4
  from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
5
- from telegram.ext import ApplicationBuilder, MessageHandler, filters, ContextTypes, CallbackQueryHandler
6
- from transformers import pipeline
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- pipe = pipeline(
9
- "text-generation",
10
- model="Qwen/Qwen2-0.5B-Instruct"
 
 
11
  )
12
 
13
- # 🧠 muistia käyttäjille
14
- memory = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- # 🛠️ TOOLS
 
 
 
 
 
 
 
 
 
 
17
 
18
- def tool_calculate(expression: str):
19
  try:
20
- return str(eval(expression))
21
- except:
22
- return "Error"
 
 
 
 
 
 
23
 
24
- def tool_search(query: str):
25
- return f"(fake search result for: {query})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  TOOLS = {
28
- "calculate": tool_calculate,
29
- "search": tool_search
30
  }
31
 
32
- # 🧠 PROMPT
33
-
34
- def build_prompt(user_id, msg):
35
- history = memory.get(user_id, [])
36
- history_text = "\n".join(history[-5:])
37
-
38
- return (
39
- "<|system|>\n"
40
- "You are an AI agent.\n"
41
- "You can:\n"
42
- "- Answer normally\n"
43
- "- Use tools\n"
44
- "- Create buttons\n\n"
45
-
46
- "TOOLS FORMAT:\n"
47
- '{"type":"tool","name":"calculate","input":"2+2"}\n'
48
- '{"type":"tool","name":"search","input":"weather Helsinki"}\n\n'
49
-
50
- "BUTTON FORMAT:\n"
51
- '{"type":"buttons","text":"Choose:","buttons":["A","B"]}\n\n'
52
-
53
- "RULES:\n"
54
- "- Only output JSON when using tools or buttons\n"
55
- "- No explanation around JSON\n"
56
- "- Otherwise reply normally\n"
57
- "- Use tools when useful\n"
58
- "- Use buttons for choices\n"
59
- "<|end|>\n"
60
-
61
- f"{history_text}\n"
62
-
63
- "<|user|>\n"
64
- f"{msg}"
65
- "<|end|>\n"
66
- "<|assistant|>\n"
67
  )
68
-
69
- # 🤖 AGENT LOOP
70
-
71
- def run_agent(user_id, msg):
72
- prompt = build_prompt(user_id, msg)
73
-
74
- for _ in range(3): # max 3 tool calls
75
- out = pipe(
76
- prompt,
77
- max_new_tokens=120,
78
- temperature=0.6,
79
- top_p=0.9,
80
- do_sample=True
81
  )
82
-
83
- text = out[0]["generated_text"]
84
- reply = text.split("<|assistant|>")[-1].strip()
85
-
86
- # yritä parse JSON
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  try:
88
- data = json.loads(reply)
89
-
90
- # 🔘 BUTTONS
91
- if data.get("type") == "buttons":
92
- return {"buttons": data}
93
-
94
- # 🛠️ TOOL CALL
95
- if data.get("type") == "tool":
96
- name = data.get("name")
97
- inp = data.get("input")
98
-
99
- if name in TOOLS:
100
- result = TOOLS[name](inp)
101
-
102
- # lisää contextiin
103
- prompt += f"\nTool result: {result}\nContinue.\n"
104
- continue
105
-
106
- except:
107
  pass
108
 
109
- return {"text": reply}
110
-
111
- return {"text": "I couldn't complete that."}
112
-
113
- # 📩 MESSAGE HANDLER
114
-
115
- async def handle(update: Update, context: ContextTypes.DEFAULT_TYPE):
116
- user_id = update.effective_user.id
117
- user_msg = update.message.text
118
-
119
- # ⌨️ typing
120
- await update.message.chat.send_action("typing")
121
-
122
- result = run_agent(user_id, user_msg)
123
-
124
- # tallenna muisti
125
- memory.setdefault(user_id, []).append(f"User: {user_msg}")
126
-
127
- if "buttons" in result:
128
- data = result["buttons"]
129
-
130
- keyboard = [
131
- [InlineKeyboardButton(b, callback_data=b)]
132
- for b in data["buttons"]
133
- ]
134
-
135
- await update.message.reply_text(
136
- data["text"],
137
  reply_markup=InlineKeyboardMarkup(keyboard)
138
  )
 
 
 
 
 
139
 
140
- memory[user_id].append(f"Assistant: {data['text']}")
 
141
  return
 
 
 
 
142
 
143
- reply = result["text"]
 
 
 
144
 
145
- memory[user_id].append(f"Assistant: {reply}")
146
 
147
- await update.message.reply_text(reply)
 
 
148
 
149
- # 🔘 BUTTON HANDLER
 
 
150
 
151
- async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
152
- query = update.callback_query
153
- await query.answer()
154
 
155
- # treat as new user input
156
- fake_update = Update(
157
- update.update_id,
158
- message=query.message
159
- )
160
- fake_update.message.text = query.data
161
 
162
- await handle(fake_update, context)
 
 
 
 
 
 
163
 
164
- # 🚀 START
 
 
165
 
166
- app = ApplicationBuilder().token(os.environ["TELEGRAM_TOKEN"]).build()
 
 
 
167
 
168
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle))
169
- app.add_handler(CallbackQueryHandler(button_handler))
170
 
171
- app.run_polling()
 
 
1
  import os
2
  import json
3
+ import ast
4
  import math
5
+ import asyncio
6
+ from collections import defaultdict, deque
7
+
8
+ import wikipedia
9
+ import torch
10
  from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
11
+ from telegram.constants import ChatAction
12
+ from telegram.ext import (
13
+ ApplicationBuilder,
14
+ MessageHandler,
15
+ CommandHandler,
16
+ CallbackQueryHandler,
17
+ ContextTypes,
18
+ filters,
19
+ )
20
+ from transformers import AutoTokenizer, AutoModelForCausalLM
21
+
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
+
69
+ def safe_calculate(expression: str) -> str:
70
+ allowed_names = {
71
+ "abs": abs,
72
+ "round": round,
73
+ "min": min,
74
+ "max": max,
75
+ "pow": pow,
76
+ "sqrt": math.sqrt,
77
+ "sin": math.sin,
78
+ "cos": math.cos,
79
+ "tan": math.tan,
80
+ "pi": math.pi,
81
+ "e": math.e,
82
+ }
83
+
84
+ allowed_nodes = (
85
+ ast.Expression,
86
+ ast.BinOp,
87
+ ast.UnaryOp,
88
+ ast.Num,
89
+ ast.Constant,
90
+ ast.Add,
91
+ ast.Sub,
92
+ ast.Mult,
93
+ ast.Div,
94
+ ast.FloorDiv,
95
+ ast.Mod,
96
+ ast.Pow,
97
+ ast.USub,
98
+ ast.UAdd,
99
+ ast.Load,
100
+ ast.Call,
101
+ ast.Name,
102
+ ast.Tuple,
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):
110
+ return "Virhe: laskua ei voitu suorittaa turvallisesti."
111
+ if isinstance(node, ast.Call):
112
+ if not isinstance(node.func, ast.Name):
113
+ return "Virhe: laskua ei voitu suorittaa turvallisesti."
114
+ if node.func.id not in allowed_names:
115
+ return "Virhe: laskua ei voitu suorittaa turvallisesti."
116
+ if isinstance(node, ast.Name):
117
+ if node.id not in allowed_names:
118
+ return "Virhe: laskua ei voitu suorittaa turvallisesti."
119
 
 
120
  try:
121
+ result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}}, allowed_names)
122
+ return str(result)
123
+ except Exception:
124
+ return "Virhe: laskua ei voitu suorittaa."
125
+
126
+ def wiki_search(query: str) -> str:
127
+ query = query.strip()
128
+ if not query:
129
+ return "Virhe: tyhjä hakukysely."
130
 
131
+ try:
132
+ wikipedia.set_lang("fi")
133
+ try:
134
+ return wikipedia.summary(query, sentences=3, auto_suggest=True)
135
+ except Exception:
136
+ page = wikipedia.page(query, auto_suggest=True)
137
+ return page.summary[:1200]
138
+ except Exception:
139
+ try:
140
+ wikipedia.set_lang("en")
141
+ try:
142
+ return wikipedia.summary(query, sentences=3, auto_suggest=True)
143
+ except Exception:
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]:]
198
+ text = tokenizer.decode(new_tokens, skip_special_tokens=False)
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
+
213
+ if response_type == "final":
214
+ text = clean_text(str(data.get("text", "")))
215
+ if not text:
216
+ text = "En saanut muodostettua vastausta."
217
+ return {"type": "final", "text": text}
218
+
219
+ if response_type == "buttons":
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")
235
+ arguments = data.get("arguments", {})
236
+
237
+ if name not in TOOLS:
238
+ return {"type": "final", "text": f"Tuntematon työkalu: {name}"}
239
+
240
+ if not isinstance(arguments, dict):
241
+ return {"type": "final", "text": "Työkalun argumentit olivat virheelliset."}
242
+
243
+ if name == "wiki_search":
244
+ query = str(arguments.get("query", "")).strip()
245
+ result = TOOLS[name](query)
246
+ elif name == "calculate":
247
+ expression = str(arguments.get("expression", "")).strip()
248
+ result = TOOLS[name](expression)
249
+ else:
250
+ result = "Työkalua ei voitu suorittaa."
251
+
252
+ messages.append({"role": "assistant", "content": json.dumps(data, ensure_ascii=False)})
253
+ messages.append({
254
+ "role": "user",
255
+ "content": f"Tool result for {name}:\n{result}\nNow continue and respond with exactly one JSON object."
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
+
263
+ async def typing_loop(chat, stop_event: asyncio.Event):
264
+ while not stop_event.is_set():
265
  try:
266
+ await chat.send_action(ChatAction.TYPING)
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:
280
+ stop_event.set()
281
+ await typing_task
282
+
283
+ memory[user_id].append({"role": "user", "content": text})
284
+
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']}")])
291
+
292
+ memory[user_id].append({
293
+ "role": "assistant",
294
+ "content": json.dumps(
295
+ {"type": "buttons", "text": result["text"], "buttons": result["buttons"]},
296
+ ensure_ascii=False
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):
319
+ query = update.callback_query
320
+ if not query or not update.effective_user:
321
+ return
322
 
323
+ await query.answer()
324
 
325
+ data = query.data or ""
326
+ if not data.startswith("btn:"):
327
+ return
328
 
329
+ button_id = data[4:]
330
+ user_id = update.effective_user.id
331
+ label = button_state[user_id].get(button_id)
332
 
333
+ if not label:
334
+ await query.message.reply_text("Tämä valinta ei ole enää voimassa.")
335
+ return
336
 
337
+ await process_user_text(query.message, context, user_id, label)
338
+
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:
346
+ return
347
+ user_id = update.effective_user.id
348
+ memory[user_id].clear()
349
+ button_state[user_id].clear()
350
+ await update.message.reply_text("Muisti nollattu.")
351
 
352
+ def main():
353
+ token = os.environ["TELEGRAM_TOKEN"]
354
+ app = ApplicationBuilder().token(token).build()
355
 
356
+ app.add_handler(CommandHandler("start", start))
357
+ app.add_handler(CommandHandler("reset", reset_chat))
358
+ app.add_handler(CallbackQueryHandler(handle_button))
359
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
360
 
361
+ app.run_polling(drop_pending_updates=True)
 
362
 
363
+ if __name__ == "__main__":
364
+ main()